Repository: google/exposure-notifications-server
Pull request #480 (2020-05-29 01:46:13)

Adding clear-export sql script

google-prow-robot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by: *<a href="https://github.com/google/exposure-notifications-server/pull/480#" title="Author self-approved">mgulimonov</a>*
To complete the [pull request process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process), please assign **mikehelmick**
You can assign the PR to them by writing `/assign @mikehelmick` in a comment when ready.

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=google%2Fexposure-notifications-server).

<details open>
Needs approval from an approver in each of these files:

- **[OWNERS](https://github.com/google/exposure-notifications-server/blob/master/OWNERS)**

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":["mikehelmick"]} -->
mgulimonov: /assign @mikehelmick
mgulimonov: >> Whats the reason for this instead of just killing the contianer?
Having script like this is useful for manual testing and e2e testing to avoid restarting entire application.
Later e2e tests will restart app and db automatically but for now it's not the case. 
sethvargo: If the test data is large, it's going to be _slower_ to truncate all the tables (because it rebuilds the indexes) vs just kicking the container.
mgulimonov: Agreed, but this won't be the case for dev environment. My current use case is to create ~100-1000 keys, 10 export batches do the tests and then clear the tables.
There is no much sense in recreating DB container for each test run.

Patch:
diff --git a/.gitignore b/.gitignore
index a52567a..df5cb8c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,7 +14,7 @@
 # Output of terraform
 *.tfstate*
 *.terraform/
-terraform.tfvars
+*.tfvars
 terraform/state.tf
 
 /local/
diff --git a/docs/deploying.md b/docs/deploying.md
index 4d01f3e..93e7b9c 100644
--- a/docs/deploying.md
+++ b/docs/deploying.md
@@ -11,6 +11,11 @@
   - [On Google Cloud](#on-google-cloud)
   - [On a custom setup](#on-a-custom-setup)
 - [Configuring the server](#configuring-the-server)
+- [Configuring components](#configuring-components)
+  - [Blob storage](#blob-storage)
+  - [Key management](#key-management)
+  - [Secrets management](#secrets-management)
+  - [Observability](#observability)
 
 <!-- /TOC -->
 
@@ -262,3 +267,62 @@ the public Internet!**
 1.  Open a browser to [localhost:8080](http://localhost:8080/).
 
     **Remember, you are editing the live configuration of the database!**
+
+
+## Configuring components
+
+### Blob storage
+
+The blob storage component defines where and how export files are written. The
+following configurations are available:
+
+| Name                   | `BLOBSTORE` value      | Description
+| ---------------------- | ---------------------- | -----------
+| Google Cloud Storage\* | `GOOGLE_CLOUD_STORAGE` | Store data in Google Cloud Storage.
+| Filesystem             | `FILESYSTEM`           | Store data on a filesystem.
+| Noop                   | `NOOP`                 | No files are written.
+
+\* default
+
+### Key management
+
+The key management component is responsible for signing exports. The following
+configurations are available:
+
+| Name               | `KEY_MANAGER` value | Description
+| ------------------ | ------------------- | -----------
+| Google Cloud KMS\* | `GOOGLE_CLOUD_KMS`  | Perform signing using Google Cloud KMS.
+| HashiCorp Vault    | `HASHICORP_VAULT`   | Perform signing using HashiCorp Vault.
+
+\* default
+
+
+### Secrets management
+
+The secrets management component is responsible for acquiring secrets. The
+following configurations are available:
+
+| Name                    | `SECRET_MANAGER` value  | Description
+| ----------------------- | ----------------------- | -----------
+| Azure Key Vault         | `AZURE_KEY_VAULT`       | Resolve with Key Vault.
+| Google Secret Manager\* | `GOOGLE_SECRET_MANAGER` | Resolve with Secret Manager.
+| HashiCorp Vault         | `HASHICORP_VAULT`       | Resolve with Vault.
+
+\* default
+
+In the interest of performance, secrets are TTL-cached to eliminate the secret
+manager from most hot paths. Configure this TTL with `SECRET_CACHE_TTL` which is
+expressed as a time duration like "5m" or "15s". The default cache time is 5
+minutes and lower values are strongly discouraged.
+
+### Observability
+
+The observability component is responsible for metrics. The following
+configurations are available:
+
+| Name                    | `OBSERVABILITY_EXPORTER` value  | Description
+| ----------------------- | ------------------------------- | -----------
+| OpenCensus Agent        | `OCAGENT`                       | Use OpenCensus.
+| Stackdriver\*           | `STACKDRIVER`                   | Use Stackdriver.
+
+\* default
diff --git a/internal/admin/authorizedapps/form.go b/internal/admin/authorizedapps/form.go
index d6108a2..6251adb 100644
--- a/internal/admin/authorizedapps/form.go
+++ b/internal/admin/authorizedapps/form.go
@@ -73,11 +73,11 @@ func (f *formData) PopulateAuthorizedApp(a *model.AuthorizedApp) error {
 	var err error
 	a.SafetyNetPastTime, err = time.ParseDuration(f.SafetyNetPastTime)
 	if err != nil {
-		return fmt.Errorf("Failed to parse durection for SafetyNetPastTime: %w", err)
+		return fmt.Errorf("failed to parse durection for SafetyNetPastTime: %w", err)
 	}
 	a.SafetyNetFutureTime, err = time.ParseDuration(f.SafetyNetFutureTime)
 	if err != nil {
-		return fmt.Errorf("Failed to parse durection for SafetyNetFutureTime: %w", err)
+		return fmt.Errorf("failed to parse durection for SafetyNetFutureTime: %w", err)
 	}
 	// DeviceCheck
 	a.DeviceCheckDisabled = f.DeviceCheckDisabled
diff --git a/internal/admin/authorizedapps/save.go b/internal/admin/authorizedapps/save.go
index a548906..d9f57bd 100644
--- a/internal/admin/authorizedapps/save.go
+++ b/internal/admin/authorizedapps/save.go
@@ -32,7 +32,7 @@ type saveController struct {
 	env    *serverenv.ServerEnv
 }
 
-func NewSave(c *admin.Config, env *serverenv.ServerEnv) *saveController {
+func NewSave(c *admin.Config, env *serverenv.ServerEnv) admin.Controller {
 	return &saveController{config: c, env: env}
 }
 
@@ -90,7 +90,6 @@ func (h *saveController) Execute(c *gin.Context) {
 		m["previousKey"] = base64.StdEncoding.EncodeToString([]byte(authApp.AppPackageName))
 		c.HTML(http.StatusOK, "authorizedapp", m)
 		return
-
 	} else if form.Action == "delete" {
 		priorKey := form.PriorKey()
 
diff --git a/internal/admin/authorizedapps/view.go b/internal/admin/authorizedapps/view.go
index f98cbe4..af7f392 100644
--- a/internal/admin/authorizedapps/view.go
+++ b/internal/admin/authorizedapps/view.go
@@ -31,7 +31,7 @@ type viewController struct {
 	env    *serverenv.ServerEnv
 }
 
-func NewView(c *admin.Config, env *serverenv.ServerEnv) *viewController {
+func NewView(c *admin.Config, env *serverenv.ServerEnv) admin.Controller {
 	return &viewController{config: c, env: env}
 }
 
diff --git a/internal/admin/config.go b/internal/admin/config.go
index 6c4e8e9..0b81ec5 100644
--- a/internal/admin/config.go
+++ b/internal/admin/config.go
@@ -23,22 +23,43 @@ import (
 	"net/http"
 
 	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/secrets"
 	"github.com/google/exposure-notifications-server/internal/setup"
+	"github.com/google/exposure-notifications-server/internal/signing"
+	"github.com/google/exposure-notifications-server/internal/storage"
 )
 
-var _ setup.DBConfigProvider = (*Config)(nil)
+var _ setup.DatabaseConfigProvider = (*Config)(nil)
+var _ setup.KeyManagerConfigProvider = (*Config)(nil)
+var _ setup.SecretManagerConfigProvider = (*Config)(nil)
+var _ setup.BlobstoreConfigProvider = (*Config)(nil)
 
 type Config struct {
+	Database      database.Config
+	KeyManager    signing.Config
+	SecretManager secrets.Config
+	Storage       storage.Config
+
 	Port         string `envconfig:"PORT" default:"8080"`
 	TemplatePath string `envconfig:"TEMPLATE_DIR" default:"./tools/admin-console/templates"`
-	Database     *database.Config
 	TopFile      string `envconfig:"TOP_FILE" default:"top"`
 	BotFile      string `envconfig:"BOTTOM_FILE" default:"bottom"`
 }
 
-// DB returns the configuration for the databse.
-func (c *Config) DB() *database.Config {
-	return c.Database
+func (c *Config) DatabaseConfig() *database.Config {
+	return &c.Database
+}
+
+func (c *Config) KeyManagerConfig() *signing.Config {
+	return &c.KeyManager
+}
+
+func (c *Config) SecretManagerConfig() *secrets.Config {
+	return &c.SecretManager
+}
+
+func (c *Config) BlobstoreConfig() *storage.Config {
+	return &c.Storage
 }
 
 func (c *Config) RenderTemplate(w http.ResponseWriter, tmpl string, p TemplateMap) error {
@@ -51,14 +72,14 @@ func (c *Config) RenderTemplate(w http.ResponseWriter, tmpl string, p TemplateMa
 	t, err := template.ParseFiles(files...)
 	if err != nil {
 		w.WriteHeader(http.StatusInternalServerError)
-		w.Write([]byte(err.Error()))
+		fmt.Fprint(w, err)
 		log.Printf("ERROR: %v", err)
 		return err
 	}
 	if err := t.ExecuteTemplate(w, tmpl, p); err != nil {
 		message := fmt.Sprintf("error rendering template: %v", err)
 		w.WriteHeader(http.StatusInternalServerError)
-		w.Write([]byte(message))
+		fmt.Fprint(w, message)
 		log.Printf("ERROR: %v", err)
 		return fmt.Errorf("error rendering template: %w", err)
 	}
diff --git a/internal/admin/controller.go b/internal/admin/controller.go
index fb77392..0d26a6e 100644
--- a/internal/admin/controller.go
+++ b/internal/admin/controller.go
@@ -6,8 +6,8 @@ import (
 	"github.com/gin-gonic/gin"
 )
 
-// Controller is the interfactor for controlers that can be pluggied into
-// Gin for the admin console portion of this project.
+// Controller is the interfactor for controllers that can be pluggied into Gin
+// for the admin console portion of this project.
 type Controller interface {
 	Execute(g *gin.Context)
 }
diff --git a/internal/admin/exports/save.go b/internal/admin/exports/save.go
index 7349a2e..4e6053d 100644
--- a/internal/admin/exports/save.go
+++ b/internal/admin/exports/save.go
@@ -31,7 +31,7 @@ type saveController struct {
 	env    *serverenv.ServerEnv
 }
 
-func NewSave(c *admin.Config, env *serverenv.ServerEnv) *saveController {
+func NewSave(c *admin.Config, env *serverenv.ServerEnv) admin.Controller {
 	return &saveController{config: c, env: env}
 }
 
diff --git a/internal/admin/exports/view.go b/internal/admin/exports/view.go
index b1a680f..9f2c3e8 100644
--- a/internal/admin/exports/view.go
+++ b/internal/admin/exports/view.go
@@ -32,7 +32,7 @@ type viewController struct {
 	env    *serverenv.ServerEnv
 }
 
-func NewView(c *admin.Config, env *serverenv.ServerEnv) *viewController {
+func NewView(c *admin.Config, env *serverenv.ServerEnv) admin.Controller {
 	return &viewController{config: c, env: env}
 }
 
diff --git a/internal/admin/index/index.go b/internal/admin/index/index.go
index 0fd77fb..1815737 100644
--- a/internal/admin/index/index.go
+++ b/internal/admin/index/index.go
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-// Package index contains admin console handler for the main landing page.
+// Package index contains admin console indexHandler for the main landing page.
 package index
 
 import (
@@ -30,7 +30,7 @@ type indexHandler struct {
 	env    *serverenv.ServerEnv
 }
 
-func New(c *admin.Config, env *serverenv.ServerEnv) *indexHandler {
+func New(c *admin.Config, env *serverenv.ServerEnv) admin.Controller {
 	return &indexHandler{config: c, env: env}
 }
 
diff --git a/internal/admin/siginfo/save.go b/internal/admin/siginfo/save.go
index fcde5c7..d968a39 100644
--- a/internal/admin/siginfo/save.go
+++ b/internal/admin/siginfo/save.go
@@ -31,7 +31,7 @@ type saveController struct {
 	env    *serverenv.ServerEnv
 }
 
-func NewSave(c *admin.Config, env *serverenv.ServerEnv) *saveController {
+func NewSave(c *admin.Config, env *serverenv.ServerEnv) admin.Controller {
 	return &saveController{config: c, env: env}
 }
 
@@ -56,6 +56,10 @@ func (h *saveController) Execute(c *gin.Context) {
 	}
 	if sigID != 0 {
 		sigInfo, err = exportDB.GetSignatureInfo(ctx, int64(sigID))
+		if err != nil {
+			admin.ErrorPage(c, fmt.Sprintf("error processing signature info: %v", err))
+			return
+		}
 	}
 	if err := form.PopulateSigInfo(sigInfo); err != nil {
 		admin.ErrorPage(c, fmt.Sprintf("error processing signature info: %v", err))
diff --git a/internal/admin/siginfo/view.go b/internal/admin/siginfo/view.go
index 9dd029d..4a05d27 100644
--- a/internal/admin/siginfo/view.go
+++ b/internal/admin/siginfo/view.go
@@ -30,7 +30,7 @@ type viewController struct {
 	env    *serverenv.ServerEnv
 }
 
-func NewView(c *admin.Config, env *serverenv.ServerEnv) *viewController {
+func NewView(c *admin.Config, env *serverenv.ServerEnv) admin.Controller {
 	return &viewController{config: c, env: env}
 }
 
diff --git a/internal/android/safetynet.go b/internal/android/safetynet.go
index 26d9505..ab7f78c 100644
--- a/internal/android/safetynet.go
+++ b/internal/android/safetynet.go
@@ -31,7 +31,7 @@ import (
 	"github.com/dgrijalva/jwt-go"
 )
 
-// The VerifyOpts determine the fields that are required for verification
+// The VerifyOpts determine the fields that are required for verification.
 type VerifyOpts struct {
 	AppPkgName      string
 	APKDigest       []string
@@ -165,7 +165,7 @@ func VerifyOptsFor(c *model.AuthorizedApp, from time.Time, nonce string) *Verify
 
 // The keyFunc is based on the Android sample code
 // https://github.com/googlesamples/android-play-safetynet/blob/d7513a54e2f28c0dcd7f8d8d0fa03adb5d87b91a/server/java/src/main/java/OfflineVerify.java
-func keyFunc(ctx context.Context, tok *jwt.Token) (interface{}, error) {
+func keyFunc(tok *jwt.Token) (interface{}, error) {
 	x5c, ok := tok.Header["x5c"].([]interface{})
 	if !ok || len(x5c) == 0 {
 		return nil, fmt.Errorf("attestation is missing certificate")
@@ -215,11 +215,7 @@ func verifyAttestation(ctx context.Context, signedAttestation string) (jwt.MapCl
 	defer trace.StartRegion(ctx, "verifyAttestation").End()
 	// jwt.Parse also validates the signature after extracting
 	// the key via the keyFunc, which validates the certificate chain.
-	token, err := jwt.Parse(signedAttestation,
-		func(tok *jwt.Token) (interface{}, error) {
-			return keyFunc(ctx, tok)
-		})
-
+	token, err := jwt.Parse(signedAttestation, keyFunc)
 	if err != nil {
 		return nil, fmt.Errorf("jwt.Parse: %w", err)
 	}
diff --git a/internal/authorizedapp/database/authorized_app.go b/internal/authorizedapp/database/authorized_app.go
index 4e870cc..f43ab86 100644
--- a/internal/authorizedapp/database/authorized_app.go
+++ b/internal/authorizedapp/database/authorized_app.go
@@ -17,6 +17,7 @@ package database
 import (
 	"context"
 	"database/sql"
+	"errors"
 	"fmt"
 	"strings"
 	"time"
@@ -47,12 +48,12 @@ func (aa *AuthorizedAppDB) InsertAuthorizedApp(ctx context.Context, m *model.Aut
 		result, err := tx.Exec(ctx, `
 			INSERT INTO
 				AuthorizedApp
-			  (app_package_name, platform, allowed_regions,
+			  (app_package_name, platform, allowed_regions, allowed_health_authority_ids, bypass_health_authority_verification,
 			   safetynet_disabled, safetynet_apk_digest, safetynet_cts_profile_match, safetynet_basic_integrity, safetynet_past_seconds, safetynet_future_seconds,
 			   devicecheck_disabled, devicecheck_team_id, devicecheck_key_id, devicecheck_private_key_secret)
 			VALUES
-			  ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
-		`, m.AppPackageName, m.Platform, m.AllAllowedRegions(),
+			  ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
+		`, m.AppPackageName, m.Platform, m.AllAllowedRegions(), m.AllAllowedHealthAuthorityIDs(), m.BypassHealthAuthorityVerification,
 			m.SafetyNetDisabled, m.SafetyNetApkDigestSHA256, m.SafetyNetCTSProfileMatch, m.SafetyNetBasicIntegrity, int64(m.SafetyNetPastTime.Seconds()), int64(m.SafetyNetFutureTime.Seconds()),
 			m.DeviceCheckDisabled, m.DeviceCheckTeamID, m.DeviceCheckKeyID, m.DeviceCheckPrivateKeySecret)
 
@@ -71,12 +72,12 @@ func (aa *AuthorizedAppDB) UpdateAuthorizedApp(ctx context.Context, priorKey str
 		result, err := tx.Exec(ctx, `
 			UPDATE AuthorizedApp
 			SET
-				app_package_name = $1, platform = $2, allowed_regions = $3,
-				safetynet_disabled = $4, safetynet_apk_digest = $5, safetynet_cts_profile_match = $6, safetynet_basic_integrity = $7, safetynet_past_seconds = $8, safetynet_future_seconds = $9,
-				devicecheck_disabled = $10, devicecheck_team_id = $11, devicecheck_key_id = $12, devicecheck_private_key_secret = $13
+				app_package_name = $1, platform = $2, allowed_regions = $3, allowed_health_authority_ids = $4, bypass_health_authority_verification = $5,
+				safetynet_disabled = $6, safetynet_apk_digest = $7, safetynet_cts_profile_match = $8, safetynet_basic_integrity = $9, safetynet_past_seconds = $10, safetynet_future_seconds = $11,
+				devicecheck_disabled = $12, devicecheck_team_id = $13, devicecheck_key_id = $14, devicecheck_private_key_secret = $15
 			WHERE
-				app_package_name = $14
-			`, m.AppPackageName, m.Platform, m.AllAllowedRegions(),
+				app_package_name = $16
+			`, m.AppPackageName, m.Platform, m.AllAllowedRegions(), m.AllAllowedHealthAuthorityIDs(), m.BypassHealthAuthorityVerification,
 			m.SafetyNetDisabled, m.SafetyNetApkDigestSHA256, m.SafetyNetCTSProfileMatch, m.SafetyNetBasicIntegrity, int64(m.SafetyNetPastTime.Seconds()), int64(m.SafetyNetFutureTime.Seconds()),
 			m.DeviceCheckDisabled, m.DeviceCheckTeamID, m.DeviceCheckKeyID, m.DeviceCheckPrivateKeySecret, priorKey)
 		if err != nil {
@@ -121,13 +122,13 @@ func (aa *AuthorizedAppDB) GetAllAuthorizedApps(ctx context.Context, sm secrets.
 	defer conn.Release()
 
 	query := `
-    SELECT
-      app_package_name, platform, allowed_regions,
-      safetynet_disabled, safetynet_apk_digest, safetynet_cts_profile_match, safetynet_basic_integrity, safetynet_past_seconds, safetynet_future_seconds,
-      devicecheck_disabled, devicecheck_team_id, devicecheck_key_id, devicecheck_private_key_secret
-    FROM
-      AuthorizedApp
-    ORDER BY app_package_name ASC`
+		SELECT
+			app_package_name, platform, allowed_regions, allowed_health_authority_ids, bypass_health_authority_verification,
+			safetynet_disabled, safetynet_apk_digest, safetynet_cts_profile_match, safetynet_basic_integrity, safetynet_past_seconds, safetynet_future_seconds,
+			devicecheck_disabled, devicecheck_team_id, devicecheck_key_id, devicecheck_private_key_secret
+		FROM
+			AuthorizedApp
+		ORDER BY app_package_name ASC`
 
 	rows, err := conn.Query(ctx, query)
 	if err != nil {
@@ -152,8 +153,8 @@ func (aa *AuthorizedAppDB) GetAllAuthorizedApps(ctx context.Context, sm secrets.
 
 // GetAuthorizedApp loads a single AuthorizedApp for the given name. If no row
 // exists, this returns nil.
-func (db *AuthorizedAppDB) GetAuthorizedApp(ctx context.Context, sm secrets.SecretManager, name string) (*model.AuthorizedApp, error) {
-	conn, err := db.db.Pool.Acquire(ctx)
+func (aa *AuthorizedAppDB) GetAuthorizedApp(ctx context.Context, sm secrets.SecretManager, name string) (*model.AuthorizedApp, error) {
+	conn, err := aa.db.Pool.Acquire(ctx)
 	if err != nil {
 		return nil, fmt.Errorf("acquiring connection: %v", err)
 	}
@@ -161,7 +162,7 @@ func (db *AuthorizedAppDB) GetAuthorizedApp(ctx context.Context, sm secrets.Secr
 
 	query := `
 		SELECT
-			app_package_name, platform, allowed_regions,
+			app_package_name, platform, allowed_regions, allowed_health_authority_ids, bypass_health_authority_verification,
 			safetynet_disabled, safetynet_apk_digest, safetynet_cts_profile_match, safetynet_basic_integrity, safetynet_past_seconds, safetynet_future_seconds,
 			devicecheck_disabled, devicecheck_team_id, devicecheck_key_id, devicecheck_private_key_secret
 		FROM
@@ -176,14 +177,15 @@ func (db *AuthorizedAppDB) GetAuthorizedApp(ctx context.Context, sm secrets.Secr
 func scanOneAuthorizedApp(ctx context.Context, row pgx.Row, sm secrets.SecretManager) (*model.AuthorizedApp, error) {
 	config := model.NewAuthorizedApp()
 	var allowedRegions []string
+	var AllowedHealthAuthorityIDs []int64
 	var safetyNetPastSeconds, safetyNetFutureSeconds *int
 	var deviceCheckTeamID, deviceCheckKeyID, deviceCheckPrivateKeySecret sql.NullString
 	if err := row.Scan(
-		&config.AppPackageName, &config.Platform, &allowedRegions,
+		&config.AppPackageName, &config.Platform, &allowedRegions, &AllowedHealthAuthorityIDs, &config.BypassHealthAuthorityVerification,
 		&config.SafetyNetDisabled, &config.SafetyNetApkDigestSHA256, &config.SafetyNetCTSProfileMatch, &config.SafetyNetBasicIntegrity, &safetyNetPastSeconds, &safetyNetFutureSeconds,
 		&config.DeviceCheckDisabled, &deviceCheckTeamID, &deviceCheckKeyID, &deviceCheckPrivateKeySecret,
 	); err != nil {
-		if err == pgx.ErrNoRows {
+		if errors.Is(err, pgx.ErrNoRows) {
 			return nil, nil
 		}
 		return nil, err
@@ -203,6 +205,10 @@ func scanOneAuthorizedApp(ctx context.Context, row pgx.Row, sm secrets.SecretMan
 	for _, r := range allowedRegions {
 		config.AllowedRegions[r] = struct{}{}
 	}
+	// and the health authorities map
+	for _, haID := range AllowedHealthAuthorityIDs {
+		config.AllowedHealthAuthorityIDs[haID] = struct{}{}
+	}
 
 	// Handle nulls
 	if v := deviceCheckTeamID; v.Valid && v.String != "" {
diff --git a/internal/authorizedapp/model/authorized_app_model.go b/internal/authorizedapp/model/authorized_app_model.go
index 238677c..69f4c7c 100644
--- a/internal/authorizedapp/model/authorized_app_model.go
+++ b/internal/authorizedapp/model/authorized_app_model.go
@@ -21,9 +21,9 @@ import (
 )
 
 const (
-	bothPlatforms = "both"
-	iosDevice     = "ios"
-	androidDevice = "android"
+	BothPlatforms = "both"
+	IosDevice     = "ios"
+	AndroidDevice = "android"
 )
 
 // AuthorizedApp represents the configuration for a single exposure notification
@@ -34,12 +34,20 @@ type AuthorizedApp struct {
 	AppPackageName string
 
 	// Platform is the app platform like "android" or "ios".
+	// Deprecated - Platform doesn't matter with device attestations going away.
 	Platform string
 
 	// AllowedRegions is the list of allowed regions for this app. If the list is
 	// empty, all regions are permitted.
 	AllowedRegions map[string]struct{}
 
+	// AllowedHealthAuthorityIDs represents the set of allowed health authorities
+	// that this app can obtain and verify diagnosis verification certificates from.
+	AllowedHealthAuthorityIDs         map[int64]struct{}
+	BypassHealthAuthorityVerification bool
+
+	// DEPRECATION NOTICE:
+	// Everything below here is deprecated.
 	// SafetyNet configuration.
 	SafetyNetDisabled        bool
 	SafetyNetApkDigestSHA256 []string
@@ -58,7 +66,8 @@ type AuthorizedApp struct {
 
 func NewAuthorizedApp() *AuthorizedApp {
 	return &AuthorizedApp{
-		AllowedRegions: make(map[string]struct{}),
+		AllowedRegions:            make(map[string]struct{}),
+		AllowedHealthAuthorityIDs: make(map[int64]struct{}),
 	}
 }
 
@@ -70,6 +79,14 @@ func (c *AuthorizedApp) AllAllowedRegions() []string {
 	return regions
 }
 
+func (c *AuthorizedApp) AllAllowedHealthAuthorityIDs() []int64 {
+	has := []int64{}
+	for k := range c.AllowedHealthAuthorityIDs {
+		has = append(has, k)
+	}
+	return has
+}
+
 func (c *AuthorizedApp) Validate() []string {
 	errors := make([]string, 0)
 	if c.AppPackageName == "" {
@@ -100,20 +117,20 @@ func (c *AuthorizedApp) Validate() []string {
 	return errors
 }
 
-// IsIOS returns true if the platform is equal to `iosDevice`
+// IsIOS returns true if the platform is equal to `iosDevice`.
 func (c *AuthorizedApp) IsIOS() bool {
-	return c.Platform == iosDevice || c.Platform == bothPlatforms
+	return c.Platform == IosDevice
 }
 
-// IsAndroid returns true if the platform is equal to `android`
+// IsAndroid returns true if the platform is equal to `android`.
 func (c *AuthorizedApp) IsAndroid() bool {
-	return c.Platform == androidDevice || c.Platform == bothPlatforms
+	return c.Platform == AndroidDevice
 }
 
 // IsDualPlatform returns true if the platform is equal to 'both'
 // i.e. AppPackageName and BundleID are the same.
 func (c *AuthorizedApp) IsDualPlatform() bool {
-	return c.Platform == bothPlatforms
+	return c.Platform == BothPlatforms
 }
 
 func (c *AuthorizedApp) RegionsOnePerLine() string {
diff --git a/internal/cleanup/config.go b/internal/cleanup/config.go
index 9de7908..99bf3e7 100644
--- a/internal/cleanup/config.go
+++ b/internal/cleanup/config.go
@@ -18,32 +18,36 @@ import (
 	"time"
 
 	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/secrets"
 	"github.com/google/exposure-notifications-server/internal/setup"
 	"github.com/google/exposure-notifications-server/internal/storage"
 )
 
 // Compile-time check to assert this config matches requirements.
-var _ setup.BlobStorageConfigProvider = (*Config)(nil)
-var _ setup.DBConfigProvider = (*Config)(nil)
+var _ setup.BlobstoreConfigProvider = (*Config)(nil)
+var _ setup.DatabaseConfigProvider = (*Config)(nil)
+var _ setup.SecretManagerConfigProvider = (*Config)(nil)
 
 // Config represents the configuration and associated environment variables for
 // the cleanup components.
 type Config struct {
-	Port          string        `envconfig:"PORT" default:"8080"`
-	Timeout       time.Duration `envconfig:"CLEANUP_TIMEOUT" default:"10m"`
-	TTL           time.Duration `envconfig:"CLEANUP_TTL" default:"336h"`
-	Database      *database.Config
-	BlobstoreType string `envconfig:"BLOBSTORE_TYPE" default:"CLOUD_STORAGE"`
+	Storage       storage.Config
+	Database      database.Config
+	SecretManager secrets.Config
+
+	Port    string        `envconfig:"PORT" default:"8080"`
+	Timeout time.Duration `envconfig:"CLEANUP_TIMEOUT" default:"10m"`
+	TTL     time.Duration `envconfig:"CLEANUP_TTL" default:"336h"`
+}
+
+func (c *Config) BlobstoreConfig() *storage.Config {
+	return &c.Storage
 }
 
-// DB return the databsae configuration.
-func (c *Config) DB() *database.Config {
-	return c.Database
+func (c *Config) DatabaseConfig() *database.Config {
+	return &c.Database
 }
 
-// BlobStorage returns the BlobStorage configuration.
-func (c *Config) BlobStorage() storage.BlobstoreConfig {
-	return storage.BlobstoreConfig{
-		BlobstoreType: storage.BlobstoreType(c.BlobstoreType),
-	}
+func (c *Config) SecretManagerConfig() *secrets.Config {
+	return &c.SecretManager
 }
diff --git a/internal/database/connection.go b/internal/database/connection.go
index 9939ba0..f21811f 100644
--- a/internal/database/connection.go
+++ b/internal/database/connection.go
@@ -39,11 +39,7 @@ func NewFromEnv(ctx context.Context, config *Config) (*DB, error) {
 	logger := logging.FromContext(ctx)
 	logger.Infof("Creating connection pool.")
 
-	connStr, err := dbConnectionString(ctx, config)
-
-	if err != nil {
-		return nil, fmt.Errorf("invalid database config: %v", err)
-	}
+	connStr := dbConnectionString(config)
 
 	pool, err := pgxpool.Connect(ctx, connStr)
 	if err != nil {
@@ -62,13 +58,13 @@ func (db *DB) Close(ctx context.Context) {
 
 // dbConnectionString builds a connection string suitable for the pgx Postgres driver, using the
 // values of vars.
-func dbConnectionString(ctx context.Context, config *Config) (string, error) {
+func dbConnectionString(config *Config) string {
 	vals := dbValues(config)
 	var p []string
 	for k, v := range vals {
 		p = append(p, fmt.Sprintf("%s=%s", k, v))
 	}
-	return strings.Join(p, " "), nil
+	return strings.Join(p, " ")
 }
 
 // dbURI builds a Postgres URI suitable for the lib/pq driver, which is used by
diff --git a/internal/database/database.go b/internal/database/database.go
index 8ed799b..2e3aad5 100644
--- a/internal/database/database.go
+++ b/internal/database/database.go
@@ -18,6 +18,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"time"
 
 	pgx "github.com/jackc/pgx/v4"
 )
@@ -30,6 +31,13 @@ var (
 	ErrKeyConflict = errors.New("key conflict")
 )
 
+func (db *DB) NullableTime(t time.Time) *time.Time {
+	if t.IsZero() {
+		return nil
+	}
+	return &t
+}
+
 // InTx runs the given function f within a transaction with isolation level isoLevel.
 func (db *DB) InTx(ctx context.Context, isoLevel pgx.TxIsoLevel, f func(tx pgx.Tx) error) error {
 	conn, err := db.Pool.Acquire(ctx)
diff --git a/internal/database/database_util.go b/internal/database/database_util.go
index 6c38b28..61f3c4c 100644
--- a/internal/database/database_util.go
+++ b/internal/database/database_util.go
@@ -16,6 +16,7 @@ package database
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"net"
 	"net/url"
@@ -42,7 +43,7 @@ import (
 //
 // All database tests can be skipped by running `go test -short` or by setting
 // the `SKIP_DATABASE_TESTS` environment variable.
-func NewTestDatabase(tb testing.TB) *DB {
+func NewTestDatabaseWithConfig(tb testing.TB) (*DB, *Config) {
 	tb.Helper()
 
 	if testing.Short() {
@@ -125,6 +126,20 @@ func NewTestDatabase(tb testing.TB) *DB {
 		db.Close(context.Background())
 	})
 
+	return db, &Config{
+		Name:     dbname,
+		User:     username,
+		Host:     container.GetBoundIP("5432/tcp"),
+		Port:     container.GetPort("5432/tcp"),
+		SSLMode:  "disable",
+		Password: password,
+	}
+}
+
+func NewTestDatabase(tb testing.TB) *DB {
+	tb.Helper()
+
+	db, _ := NewTestDatabaseWithConfig(tb)
 	return db
 }
 
@@ -137,7 +152,7 @@ func dbMigrate(u string) error {
 	if err != nil {
 		return fmt.Errorf("failed create migrate: %w", err)
 	}
-	if err := m.Up(); err != nil && err != migrate.ErrNoChange {
+	if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
 		return fmt.Errorf("failed run migrate: %w", err)
 	}
 	srcErr, dbErr := m.Close()
diff --git a/internal/export/batcher.go b/internal/export/batcher.go
index 8cf3588..dc8ae75 100644
--- a/internal/export/batcher.go
+++ b/internal/export/batcher.go
@@ -46,14 +46,18 @@ func (s *Server) CreateBatchesHandler(w http.ResponseWriter, r *http.Request) {
 			metrics.WriteInt("export-batcher-lock-contention", true, 1)
 			msg := fmt.Sprintf("Lock %s already in use, no work will be performed", lock)
 			logger.Infof(msg)
-			w.Write([]byte(msg)) // We return status 200 here so that Cloud Scheduler does not retry.
+			fmt.Fprint(w, msg) // We return status 200 here so that Cloud Scheduler does not retry.
 			return
 		}
 		logger.Errorf("Could not acquire lock %s: %v", lock, err)
 		http.Error(w, fmt.Sprintf("Could not acquire lock %s, check logs.", lock), http.StatusInternalServerError)
 		return
 	}
-	defer unlockFn()
+	defer func() {
+		if err := unlockFn(); err != nil {
+			logger.Errorf("failed to unlock: %v", err)
+		}
+	}()
 
 	totalConfigs := 0
 	totalBatches := 0
@@ -141,7 +145,6 @@ type batchRange struct {
 var sanityDate = time.Date(2019, 1, 1, 0, 0, 0, 0, time.UTC)
 
 func makeBatchRanges(period time.Duration, latestEnd, now time.Time, truncateWindow time.Duration) []batchRange {
-
 	// Compute the end of the exposure publish window; we don't want any batches with an end date greater than this time.
 	publishEnd := publishmodel.TruncateWindow(now, truncateWindow)
 
diff --git a/internal/export/config.go b/internal/export/config.go
index 9b36081..4def66e 100644
--- a/internal/export/config.go
+++ b/internal/export/config.go
@@ -18,19 +18,26 @@ import (
 	"time"
 
 	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/secrets"
 	"github.com/google/exposure-notifications-server/internal/setup"
+	"github.com/google/exposure-notifications-server/internal/signing"
 	"github.com/google/exposure-notifications-server/internal/storage"
 )
 
 // Compile-time check to assert this config matches requirements.
-var _ setup.KeyManagerProvider = (*Config)(nil)
-var _ setup.BlobStorageConfigProvider = (*Config)(nil)
-var _ setup.DBConfigProvider = (*Config)(nil)
+var _ setup.BlobstoreConfigProvider = (*Config)(nil)
+var _ setup.DatabaseConfigProvider = (*Config)(nil)
+var _ setup.KeyManagerConfigProvider = (*Config)(nil)
+var _ setup.SecretManagerConfigProvider = (*Config)(nil)
 
 // Config represents the configuration and associated environment variables for
 // the export components.
 type Config struct {
-	Database       *database.Config
+	Storage       storage.Config
+	Database      database.Config
+	KeyManager    signing.Config
+	SecretManager secrets.Config
+
 	Port           string        `envconfig:"PORT" default:"8080"`
 	CreateTimeout  time.Duration `envconfig:"CREATE_BATCHES_TIMEOUT" default:"5m"`
 	WorkerTimeout  time.Duration `envconfig:"WORKER_TIMEOUT" default:"5m"`
@@ -39,23 +46,21 @@ type Config struct {
 	MaxRecords     int           `envconfig:"EXPORT_FILE_MAX_RECORDS" default:"30000"`
 	TruncateWindow time.Duration `envconfig:"TRUNCATE_WINDOW" default:"1h"`
 	MinWindowAge   time.Duration `envconfig:"MIN_WINDOW_AGE" default:"2h"`
-	BlobstoreType  string        `envconfig:"BLOBSTORE_TYPE" default:"CLOUD_STORAGE"`
 	TTL            time.Duration `envconfig:"CLEANUP_TTL" default:"336h"`
 }
 
-// DB returns the database config.
-func (c *Config) DB() *database.Config {
-	return c.Database
+func (c *Config) BlobstoreConfig() *storage.Config {
+	return &c.Storage
+}
+
+func (c *Config) DatabaseConfig() *database.Config {
+	return &c.Database
 }
 
-// KeyManager returns the KeyManager configuration.
-func (c *Config) KeyManager() bool {
-	return true
+func (c *Config) KeyManagerConfig() *signing.Config {
+	return &c.KeyManager
 }
 
-// BlobStorage returns the BlobStorage configuration.
-func (c *Config) BlobStorage() storage.BlobstoreConfig {
-	return storage.BlobstoreConfig{
-		BlobstoreType: storage.BlobstoreType(c.BlobstoreType),
-	}
+func (c *Config) SecretManagerConfig() *secrets.Config {
+	return &c.SecretManager
 }
diff --git a/internal/export/database/export.go b/internal/export/database/export.go
index d068e7f..50e690e 100644
--- a/internal/export/database/export.go
+++ b/internal/export/database/export.go
@@ -28,10 +28,6 @@ import (
 	pgx "github.com/jackc/pgx/v4"
 )
 
-const (
-	bucketEnvVar = "EXPORT_BUCKET"
-)
-
 type ExportDB struct {
 	db *database.DB
 }
@@ -48,10 +44,7 @@ func (db *ExportDB) AddExportConfig(ctx context.Context, ec *model.ExportConfig)
 		return err
 	}
 
-	var thru *time.Time
-	if !ec.Thru.IsZero() {
-		thru = &ec.Thru
-	}
+	thru := db.db.NullableTime(ec.Thru)
 	return db.db.InTx(ctx, pgx.Serializable, func(tx pgx.Tx) error {
 		row := tx.QueryRow(ctx, `
 			INSERT INTO
@@ -76,10 +69,7 @@ func (db *ExportDB) UpdateExportConfig(ctx context.Context, ec *model.ExportConf
 		return err
 	}
 
-	var thru *time.Time
-	if !ec.Thru.IsZero() {
-		thru = &ec.Thru
-	}
+	thru := db.db.NullableTime(ec.Thru)
 	return db.db.InTx(ctx, pgx.Serializable, func(tx pgx.Tx) error {
 		result, err := tx.Exec(ctx, `
 			UPDATE
@@ -277,7 +267,7 @@ func (db *ExportDB) ListAllSigntureInfos(ctx context.Context) ([]*model.Signatur
 		if rows.Err() != nil {
 			return nil, rows.Err()
 		}
-		si, err := scanOneSignatureInfo(ctx, rows)
+		si, err := scanOneSignatureInfo(rows)
 		if err != nil {
 			return nil, err
 		}
@@ -311,7 +301,7 @@ func (db *ExportDB) LookupSignatureInfos(ctx context.Context, ids []int64, valid
 		if rows.Err() != nil {
 			return nil, rows.Err()
 		}
-		si, err := scanOneSignatureInfo(ctx, rows)
+		si, err := scanOneSignatureInfo(rows)
 		if err != nil {
 			return nil, err
 		}
@@ -338,10 +328,10 @@ func (db *ExportDB) GetSignatureInfo(ctx context.Context, id int64) (*model.Sign
 			id = $1
 		`, id)
 
-	return scanOneSignatureInfo(ctx, row)
+	return scanOneSignatureInfo(row)
 }
 
-func scanOneSignatureInfo(ctx context.Context, row pgx.Row) (*model.SignatureInfo, error) {
+func scanOneSignatureInfo(row pgx.Row) (*model.SignatureInfo, error) {
 	var info model.SignatureInfo
 	var thru *time.Time
 	if err := row.Scan(&info.ID, &info.SigningKey, &info.AppPackageName, &info.BundleID, &info.SigningKeyVersion, &info.SigningKeyID, &thru); err != nil {
@@ -356,7 +346,6 @@ func scanOneSignatureInfo(ctx context.Context, row pgx.Row) (*model.SignatureInf
 // LatestExportBatchEnd returns the end time of the most recent ExportBatch for
 // a given ExportConfig. It returns the zero time if no previous ExportBatch
 // exists.
-// TODO(squee1945): This needs a
 func (db *ExportDB) LatestExportBatchEnd(ctx context.Context, ec *model.ExportConfig) (time.Time, error) {
 	conn, err := db.db.Pool.Acquire(ctx)
 	if err != nil {
@@ -471,7 +460,6 @@ func (db *ExportDB) LeaseBatch(ctx context.Context, ttl time.Duration, now time.
 		// In a serialized transaction, fetch the existing batch and make sure it can be leased, then lease it.
 		leased := false
 		err := db.db.InTx(ctx, pgx.Serializable, func(tx pgx.Tx) error {
-
 			row := tx.QueryRow(ctx, `
 				SELECT
 					status, lease_expires
@@ -718,7 +706,6 @@ func (db *ExportDB) DeleteFilesBefore(ctx context.Context, before time.Time, blo
 	batchFileDeleteCounter := make(map[int64]int)
 
 	for _, f := range files {
-
 		// If file is already deleted, skip to the next.
 		if f.fileStatus == model.ExportBatchDeleted {
 			batchFileDeleteCounter[f.batchID]++
@@ -734,7 +721,7 @@ func (db *ExportDB) DeleteFilesBefore(ctx context.Context, before time.Time, blo
 
 		err := db.db.InTx(ctx, pgx.Serializable, func(tx pgx.Tx) error {
 			// Update Status in ExportFile.
-			if err := updateExportFileStatus(ctx, tx, f.batchID, f.filename, model.ExportBatchDeleted); err != nil {
+			if err := updateExportFileStatus(ctx, tx, f.filename, model.ExportBatchDeleted); err != nil {
 				return fmt.Errorf("updating ExportFile: %w", err)
 			}
 
@@ -777,7 +764,7 @@ func addExportFile(ctx context.Context, tx pgx.Tx, ef *model.ExportFile) error {
 	return nil
 }
 
-func updateExportFileStatus(ctx context.Context, tx pgx.Tx, batchID int64, filename, status string) error {
+func updateExportFileStatus(ctx context.Context, tx pgx.Tx, filename, status string) error {
 	_, err := tx.Exec(ctx, `
 		UPDATE
 			ExportFile
diff --git a/internal/export/exportfile.go b/internal/export/exportfile.go
index 5267bee..9ecef53 100644
--- a/internal/export/exportfile.go
+++ b/internal/export/exportfile.go
@@ -45,13 +45,13 @@ var (
 	fixedHeaderWidth = 16
 )
 
-type ExportSigners struct {
+type Signer struct {
 	SignatureInfo *model.SignatureInfo
 	Signer        crypto.Signer
 }
 
 // MarshalExportFile converts the inputs into an encoded byte array.
-func MarshalExportFile(eb *model.ExportBatch, exposures []*publishmodel.Exposure, batchNum, batchSize int, signers []ExportSigners) ([]byte, error) {
+func MarshalExportFile(eb *model.ExportBatch, exposures []*publishmodel.Exposure, batchNum, batchSize int, signers []*Signer) ([]byte, error) {
 	// create main exposure key export binary
 	expContents, err := marshalContents(eb, exposures, int32(batchNum), int32(batchSize), signers)
 	if err != nil {
@@ -59,7 +59,7 @@ func MarshalExportFile(eb *model.ExportBatch, exposures []*publishmodel.Exposure
 	}
 
 	// create signature file
-	sigContents, err := marshalSignature(eb, expContents, int32(batchNum), int32(batchSize), signers)
+	sigContents, err := marshalSignature(expContents, int32(batchNum), int32(batchSize), signers)
 	if err != nil {
 		return nil, fmt.Errorf("unable to marshal signature file: %w", err)
 	}
@@ -93,7 +93,7 @@ func MarshalExportFile(eb *model.ExportBatch, exposures []*publishmodel.Exposure
 func UnmarshalExportFile(zippedProtoPayload []byte) (*export.TemporaryExposureKeyExport, error) {
 	zp, err := zip.NewReader(bytes.NewReader(zippedProtoPayload), int64(len(zippedProtoPayload)))
 	if err != nil {
-		return nil, fmt.Errorf("Can't read payload: %v", err)
+		return nil, fmt.Errorf("can't read payload: %v", err)
 	}
 
 	for _, file := range zp.File {
@@ -102,7 +102,7 @@ func UnmarshalExportFile(zippedProtoPayload []byte) (*export.TemporaryExposureKe
 		}
 	}
 
-	return nil, fmt.Errorf("Payload is invalid: no %v file was found", exportBinaryName)
+	return nil, fmt.Errorf("payload is invalid: no %v file was found", exportBinaryName)
 }
 
 func unmarshalContent(file *zip.File) (*export.TemporaryExposureKeyExport, error) {
@@ -131,7 +131,7 @@ func unmarshalContent(file *zip.File) (*export.TemporaryExposureKeyExport, error
 	return message, nil
 }
 
-func marshalContents(eb *model.ExportBatch, exposures []*publishmodel.Exposure, batchNum int32, batchSize int32, signers []ExportSigners) ([]byte, error) {
+func marshalContents(eb *model.ExportBatch, exposures []*publishmodel.Exposure, batchNum int32, batchSize int32, signers []*Signer) ([]byte, error) {
 	exportBytes := fixedHeader
 	if len(exportBytes) != fixedHeaderWidth {
 		return nil, fmt.Errorf("incorrect header length: %d", len(exportBytes))
@@ -166,8 +166,8 @@ func marshalContents(eb *model.ExportBatch, exposures []*publishmodel.Exposure,
 		StartTimestamp: proto.Uint64(uint64(eb.StartTimestamp.Unix())),
 		EndTimestamp:   proto.Uint64(uint64(eb.EndTimestamp.Unix())),
 		Region:         proto.String(eb.OutputRegion),
-		BatchNum:       proto.Int32(int32(batchNum)),
-		BatchSize:      proto.Int32(int32(batchSize)),
+		BatchNum:       proto.Int32(batchNum),
+		BatchSize:      proto.Int32(batchSize),
 		Keys:           pbeks,
 		SignatureInfos: exportSigInfos,
 	}
@@ -195,8 +195,7 @@ func createSignatureInfo(si *model.SignatureInfo) *export.SignatureInfo {
 	return sigInfo
 }
 
-func marshalSignature(eb *model.ExportBatch, exportContents []byte, batchNum, batchSize int32, signers []ExportSigners) ([]byte, error) {
-
+func marshalSignature(exportContents []byte, batchNum, batchSize int32, signers []*Signer) ([]byte, error) {
 	var signatures []*export.TEKSignature
 	for _, s := range signers {
 		sig, err := generateSignature(exportContents, s.Signer)
diff --git a/internal/export/model/export_model.go b/internal/export/model/export_model.go
index 52d8058..ba2e01c 100644
--- a/internal/export/model/export_model.go
+++ b/internal/export/model/export_model.go
@@ -66,31 +66,31 @@ func (ec *ExportConfig) Validate() error {
 	return nil
 }
 
-func (e *ExportConfig) FormattedFromTime() string {
-	return e.From.Format(time.UnixDate)
+func (ec *ExportConfig) FormattedFromTime() string {
+	return ec.From.Format(time.UnixDate)
 }
 
-func (e *ExportConfig) FormattedThruTime() string {
-	if e.Thru.IsZero() {
+func (ec *ExportConfig) FormattedThruTime() string {
+	if ec.Thru.IsZero() {
 		return ""
 	}
-	return e.Thru.Format(time.UnixDate)
+	return ec.Thru.Format(time.UnixDate)
 }
 
-func (e *ExportConfig) FromHTMLDate() string {
-	return toHTMLDate(e.From)
+func (ec *ExportConfig) FromHTMLDate() string {
+	return toHTMLDate(ec.From)
 }
 
-func (e *ExportConfig) FromHTMLTime() string {
-	return toHTMLTime(e.From)
+func (ec *ExportConfig) FromHTMLTime() string {
+	return toHTMLTime(ec.From)
 }
 
-func (e *ExportConfig) ThruHTMLDate() string {
-	return toHTMLDate(e.Thru)
+func (ec *ExportConfig) ThruHTMLDate() string {
+	return toHTMLDate(ec.Thru)
 }
 
-func (e *ExportConfig) ThruHTMLTime() string {
-	return toHTMLTime(e.Thru)
+func (ec *ExportConfig) ThruHTMLTime() string {
+	return toHTMLTime(ec.Thru)
 }
 
 type ExportBatch struct {
@@ -173,7 +173,7 @@ func toHTMLTime(t time.Time) string {
 }
 
 func effectiveInputRegions(outRegion string, inRegions []string) []string {
-	if inRegions != nil && len(inRegions) > 0 {
+	if len(inRegions) > 0 {
 		return inRegions
 	}
 	return []string{outRegion}
diff --git a/internal/export/worker.go b/internal/export/worker.go
index b319c61..ada4201 100644
--- a/internal/export/worker.go
+++ b/internal/export/worker.go
@@ -17,6 +17,7 @@ package export
 import (
 	"context"
 	"crypto/rand"
+	"errors"
 	"fmt"
 	"math/big"
 	"net/http"
@@ -40,7 +41,8 @@ const (
 	blobOperationTimeout = 50 * time.Second
 )
 
-// WorkerHandler is a handler to iterate the rows of ExportBatch, and creates export files.
+// WorkerHandler is a handler to iterate the rows of ExportBatch, and creates
+// export files.
 func (s *Server) WorkerHandler(w http.ResponseWriter, r *http.Request) {
 	ctx, cancel := context.WithTimeout(r.Context(), s.config.WorkerTimeout)
 	defer cancel()
@@ -56,7 +58,8 @@ func (s *Server) WorkerHandler(w http.ResponseWriter, r *http.Request) {
 			return
 		}
 
-		// Only consider batches that closed a few minutes ago to allow the publish windows to close properly.
+		// Only consider batches that closed a few minutes ago to allow the publish
+		// windows to close properly.
 		minutesAgo := time.Now().Add(-5 * time.Minute)
 
 		// Check for a batch and obtain a lease for it.
@@ -76,10 +79,10 @@ func (s *Server) WorkerHandler(w http.ResponseWriter, r *http.Request) {
 			logger.Errorf("Failed to create files for batch: %v.", err)
 			continue
 		}
-		// We re-write the index file for empty batches for self-healing so that the index
-		// file reflects the ExportFile table in database. However, if a single worker
-		// processes a number of empty batches quickly, we want to avoid writing the same
-		// file repeatedly and hitting a rate limit.
+		// We re-write the index file for empty batches for self-healing so that the
+		// index file reflects the ExportFile table in database. However, if a
+		// single worker processes a number of empty batches quickly, we want to
+		// avoid writing the same file repeatedly and hitting a rate limit.
 		emitIndexForEmptyBatch = false
 
 		fmt.Fprintf(w, "Batch %d marked completed. \n", batch.BatchID)
@@ -97,9 +100,10 @@ func (s *Server) exportBatch(ctx context.Context, eb *model.ExportBatch, emitInd
 		OnlyLocalProvenance: false, // include federated ids
 	}
 
-	// Build up groups of exposures in memory. We need to use memory so we can determine the
-	// total number of groups (which is embedded in each export file). This technique avoids
-	// SELECT COUNT which would lock the database slowing new uploads.
+	// Build up groups of exposures in memory. We need to use memory so we can
+	// determine the total number of groups (which is embedded in each export
+	// file). This technique avoids SELECT COUNT which would lock the database
+	// slowing new uploads.
 	var groups [][]*publishmodel.Exposure
 	var exposures []*publishmodel.Exposure
 
@@ -144,7 +148,8 @@ func (s *Server) exportBatch(ctx context.Context, eb *model.ExportBatch, emitInd
 			return nil
 		}
 
-		// TODO(squee1945): Uploading in parallel (to a point) probably makes better use of network.
+		// TODO(squee1945): Uploading in parallel (to a point) probably makes better
+		// use of network.
 		objectName, err := s.createFile(ctx,
 			createFileInfo{
 				exposures:      exposures,
@@ -186,13 +191,13 @@ type createFileInfo struct {
 func (s *Server) createFile(ctx context.Context, cfi createFileInfo) (string, error) {
 	logger := logging.FromContext(ctx)
 
-	var signers []ExportSigners
+	var signers []*Signer
 	for _, si := range cfi.signatureInfos {
 		signer, err := s.env.GetSignerForKey(ctx, si.SigningKey)
 		if err != nil {
 			return "", fmt.Errorf("unable to get signer for key %v: %w", si.SigningKey, err)
 		}
-		signers = append(signers, ExportSigners{SignatureInfo: si, Signer: signer})
+		signers = append(signers, &Signer{SignatureInfo: si, Signer: signer})
 	}
 
 	// Generate exposure key export file.
@@ -205,14 +210,15 @@ func (s *Server) createFile(ctx context.Context, cfi createFileInfo) (string, er
 	logger.Infof("Created file %v, signed with %v keys", objectName, len(signers))
 	ctx, cancel := context.WithTimeout(ctx, blobOperationTimeout)
 	defer cancel()
-	if err := s.env.Blobstore().CreateObject(ctx, cfi.exportBatch.BucketName, objectName, data); err != nil {
+	if err := s.env.Blobstore().CreateObject(ctx, cfi.exportBatch.BucketName, objectName, data, false); err != nil {
 		return "", fmt.Errorf("creating file %s in bucket %s: %w", objectName, cfi.exportBatch.BucketName, err)
 	}
 	return objectName, nil
 }
 
-// retryingCreateIndex create the index file. The index file includes _all_ batches for an ExportConfig,
-// so multiple workers may be racing to update it. We use a lock to make them line up after one another.
+// retryingCreateIndex create the index file. The index file includes _all_
+// batches for an ExportConfig, so multiple workers may be racing to update it.
+// We use a lock to make them line up after one another.
 func (s *Server) retryingCreateIndex(ctx context.Context, eb *model.ExportBatch, objectNames []string) error {
 	logger := logging.FromContext(ctx)
 
@@ -226,7 +232,7 @@ func (s *Server) retryingCreateIndex(ctx context.Context, eb *model.ExportBatch,
 
 		unlock, err := s.db.Lock(ctx, lockID, time.Minute)
 		if err != nil {
-			if err == coredb.ErrAlreadyLocked {
+			if errors.Is(err, coredb.ErrAlreadyLocked) {
 				logger.Debugf("Lock %s is locked; sleeping %v and will try again", lockID, sleep)
 				time.Sleep(sleep)
 				continue
@@ -276,7 +282,7 @@ func (s *Server) createIndex(ctx context.Context, eb *model.ExportBatch, newObje
 	indexObjectName := exportIndexFilename(eb)
 	ctx, cancel := context.WithTimeout(ctx, blobOperationTimeout)
 	defer cancel()
-	if err := s.env.Blobstore().CreateObject(ctx, eb.BucketName, indexObjectName, data); err != nil {
+	if err := s.env.Blobstore().CreateObject(ctx, eb.BucketName, indexObjectName, data, false); err != nil {
 		return "", 0, fmt.Errorf("creating file %s in bucket %s: %w", indexObjectName, eb.BucketName, err)
 	}
 	return indexObjectName, len(objects), nil
@@ -290,7 +296,7 @@ func exportIndexFilename(eb *model.ExportBatch) string {
 	return fmt.Sprintf("%s/index.txt", eb.FilenameRoot)
 }
 
-// randomInt is inclusive, [min:max]
+// randomInt is inclusive, [min:max].
 func randomInt(min, max int) (int, error) {
 	n, err := rand.Int(rand.Reader, big.NewInt(int64(max-min+1)))
 	if err != nil {
@@ -343,7 +349,8 @@ func ensureMinNumExposures(exposures []*publishmodel.Exposure, region string, mi
 			Regions:          []string{region},
 			IntervalNumber:   intervalNumber,
 			IntervalCount:    intervalCount,
-			// The rest of the publishmodel.Exposure fields are not used in the export file.
+			// The rest of the publishmodel.Exposure fields are not used in the export
+			// file.
 		}
 		exposures = append(exposures, ek)
 	}
diff --git a/internal/federationin/config.go b/internal/federationin/config.go
index c0d4e85..e221826 100644
--- a/internal/federationin/config.go
+++ b/internal/federationin/config.go
@@ -19,6 +19,7 @@ import (
 	"time"
 
 	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/secrets"
 	"github.com/google/exposure-notifications-server/internal/setup"
 )
 
@@ -35,11 +36,14 @@ var (
 )
 
 // Compile-time check to assert this config matches requirements.
-var _ setup.DBConfigProvider = (*Config)(nil)
+var _ setup.DatabaseConfigProvider = (*Config)(nil)
+var _ setup.SecretManagerConfigProvider = (*Config)(nil)
 
 // Config is the configuration for federation-pull components (data pulled from other servers).
 type Config struct {
-	Database       *database.Config
+	Database      database.Config
+	SecretManager secrets.Config
+
 	Port           string        `envconfig:"PORT" default:"8080"`
 	Timeout        time.Duration `envconfig:"RPC_TIMEOUT" default:"10m"`
 	TruncateWindow time.Duration `envconfig:"TRUNCATE_WINDOW" default:"1h"`
@@ -56,7 +60,10 @@ type Config struct {
 	CredentialsFile string `envconfig:"CREDENTIALS_FILE"`
 }
 
-// DB returns the database config.
-func (c *Config) DB() *database.Config {
-	return c.Database
+func (c *Config) DatabaseConfig() *database.Config {
+	return &c.Database
+}
+
+func (c *Config) SecretManagerConfig() *secrets.Config {
+	return &c.SecretManager
 }
diff --git a/internal/federationin/federationin.go b/internal/federationin/federationin.go
index bc21e0c..276a1ae 100644
--- a/internal/federationin/federationin.go
+++ b/internal/federationin/federationin.go
@@ -117,13 +117,17 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 			metrics.WriteInt("federation-pull-lock-contention", true, 1)
 			msg := fmt.Sprintf("Lock %s already in use. No work will be performed.", lock)
 			logger.Infof(msg)
-			w.Write([]byte(msg)) // We return status 200 here so that Cloud Scheduler does not retry.
+			fmt.Fprint(w, msg) // We return status 200 here so that Cloud Scheduler does not retry.
 			return
 		}
 		internalErrorf(ctx, w, "Could not acquire lock %s for query %s: %v", lock, queryID, err)
 		return
 	}
-	defer unlockFn()
+	defer func() {
+		if err := unlockFn(); err != nil {
+			logger.Errorf("failed to unlock: %v", err)
+		}
+	}()
 
 	query, err := h.db.GetFederationInQuery(ctx, queryID)
 	if err != nil {
@@ -195,7 +199,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	if timeoutContext.Err() != nil && timeoutContext.Err() == context.DeadlineExceeded {
+	if errors.Is(timeoutContext.Err(), context.DeadlineExceeded) {
 		logger.Infof("Federation puller timed out at %v before fetching entire set.", h.config.Timeout)
 	}
 }
@@ -251,7 +255,6 @@ func pull(ctx context.Context, metrics metrics.Exporter, deps pullDependencies,
 		// Loop through the result set, storing in publishdb.
 		var exposures []*publishmodel.Exposure
 		for _, ctr := range response.Response {
-
 			var upperRegions []string
 			for _, region := range ctr.RegionIdentifiers {
 				upperRegions = append(upperRegions, strings.ToUpper(strings.TrimSpace(region)))
@@ -260,7 +263,6 @@ func pull(ctx context.Context, metrics metrics.Exporter, deps pullDependencies,
 
 			for _, cti := range ctr.ContactTracingInfo {
 				for _, key := range cti.ExposureKeys {
-
 					if cti.TransmissionRisk < publishmodel.MinTransmissionRisk || cti.TransmissionRisk > publishmodel.MaxTransmissionRisk {
 						logger.Errorf("invalid transmission risk %v - dropping record.", cti.TransmissionRisk)
 						continue
diff --git a/internal/federationout/config.go b/internal/federationout/config.go
index 98b9b7e..48aa0cd 100644
--- a/internal/federationout/config.go
+++ b/internal/federationout/config.go
@@ -18,15 +18,19 @@ import (
 	"time"
 
 	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/secrets"
 	"github.com/google/exposure-notifications-server/internal/setup"
 )
 
 // Compile-time check to assert this config matches requirements.
-var _ setup.DBConfigProvider = (*Config)(nil)
+var _ setup.DatabaseConfigProvider = (*Config)(nil)
+var _ setup.SecretManagerConfigProvider = (*Config)(nil)
 
 // Config is the configuration for the federation components (data sent to other servers).
 type Config struct {
-	Database       *database.Config
+	Database      database.Config
+	SecretManager secrets.Config
+
 	Port           string        `envconfig:"PORT" default:"8080"`
 	Timeout        time.Duration `envconfig:"RPC_TIMEOUT" default:"5m"`
 	TruncateWindow time.Duration `envconfig:"TRUNCATE_WINDOW" default:"1h"`
@@ -42,7 +46,10 @@ type Config struct {
 	TLSKeyFile  string `envconfig:"TLS_KEY_FILE"`
 }
 
-// DB returns the database config.
-func (c *Config) DB() *database.Config {
-	return c.Database
+func (c *Config) DatabaseConfig() *database.Config {
+	return &c.Database
+}
+
+func (c *Config) SecretManagerConfig() *secrets.Config {
+	return &c.SecretManager
 }
diff --git a/internal/generate/config.go b/internal/generate/config.go
index c0ff45a..62dd0c2 100644
--- a/internal/generate/config.go
+++ b/internal/generate/config.go
@@ -19,15 +19,20 @@ import (
 	"time"
 
 	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/secrets"
 	"github.com/google/exposure-notifications-server/internal/setup"
 )
 
 // Compile-time check to assert this config matches requirements.
-var _ setup.DBConfigProvider = (*Config)(nil)
+var _ setup.DatabaseConfigProvider = (*Config)(nil)
+var _ setup.SecretManagerConfigProvider = (*Config)(nil)
 
 // Config represents the configuration and associated environment variables for
 // the publish components.
 type Config struct {
+	Database      database.Config
+	SecretManager secrets.Config
+
 	Port             string        `envconfig:"PORT" default:"8080"`
 	NumExposures     int           `envconfig:"NUM_EXPOSURES_GENERATED" default:"10"`
 	KeysPerExposure  int           `envconfig:"KEYS_PER_EXPOSURE" default:"14"`
@@ -35,11 +40,12 @@ type Config struct {
 	MaxIntervalAge   time.Duration `envconfig:"MAX_INTERVAL_AGE_ON_PUBLISH" default:"360h"`
 	TruncateWindow   time.Duration `envconfig:"TRUNCATE_WINDOW" default:"1h"`
 	DefaultRegion    string        `envconfig:"DEFAULT_REGOIN" default:"US"`
+}
 
-	Database *database.Config
+func (c *Config) DatabaseConfig() *database.Config {
+	return &c.Database
 }
 
-// DB returns the configuration for the databse.
-func (c *Config) DB() *database.Config {
-	return c.Database
+func (c *Config) SecretManagerConfig() *secrets.Config {
+	return &c.SecretManager
 }
diff --git a/internal/generate/handler.go b/internal/generate/handler.go
index 8277313..978c25a 100644
--- a/internal/generate/handler.go
+++ b/internal/generate/handler.go
@@ -91,7 +91,7 @@ func (h *generateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 			message := fmt.Sprintf("Error transofmring generated exposures: %v", err)
 			span.SetStatus(trace.Status{Code: trace.StatusCodeInternal, Message: message})
 			w.WriteHeader(http.StatusInternalServerError)
-			w.Write([]byte(message))
+			fmt.Fprint(w, message)
 			return
 		}
 
@@ -100,11 +100,11 @@ func (h *generateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 			message := fmt.Sprintf("error writing exposure record: %v", err)
 			span.SetStatus(trace.Status{Code: trace.StatusCodeInternal, Message: message})
 			w.WriteHeader(http.StatusInternalServerError)
-			w.Write([]byte(message))
+			fmt.Fprint(w, message)
 			return
 		}
 		logger.Infof("Generated %v exposures", len(exposures))
 	}
 	w.WriteHeader(http.StatusOK)
-	w.Write([]byte("Generated exposure keys."))
+	fmt.Fprint(w, "Generated exposure keys.")
 }
diff --git a/internal/monolith/monolith_util.go b/internal/monolith/monolith_util.go
index f0c39cb..58bf7c1 100644
--- a/internal/monolith/monolith_util.go
+++ b/internal/monolith/monolith_util.go
@@ -30,41 +30,56 @@ import (
 	"github.com/google/exposure-notifications-server/internal/handlers"
 	"github.com/google/exposure-notifications-server/internal/logging"
 	"github.com/google/exposure-notifications-server/internal/publish"
+	"github.com/google/exposure-notifications-server/internal/secrets"
 	"github.com/google/exposure-notifications-server/internal/setup"
+	"github.com/google/exposure-notifications-server/internal/signing"
 	"github.com/google/exposure-notifications-server/internal/storage"
 
 	// Enable observability with distributed tracing and metrics.
 	_ "github.com/google/exposure-notifications-server/internal/observability"
 )
 
-var _ setup.DBConfigProvider = (*MonoConfig)(nil)
 var _ setup.AuthorizedAppConfigProvider = (*MonoConfig)(nil)
-var _ setup.BlobStorageConfigProvider = (*MonoConfig)(nil)
-var _ setup.KeyManagerProvider = (*MonoConfig)(nil)
+var _ setup.BlobstoreConfigProvider = (*MonoConfig)(nil)
+var _ setup.DatabaseConfigProvider = (*MonoConfig)(nil)
+var _ setup.KeyManagerConfigProvider = (*MonoConfig)(nil)
+var _ setup.SecretManagerConfigProvider = (*MonoConfig)(nil)
 
 type MonoConfig struct {
+	AuthorizedApp authorizedapp.Config
+	Storage       storage.Config
+	Cleanup       cleanup.Config
+	Database      database.Config
+	Export        export.Config
+	FederationIn  federationin.Config
+	KeyManager    signing.Config
+	Publish       publish.Config
+	SecretManager secrets.Config
+
 	Port string `envconfig:"PORT" default:"8080"`
+}
 
-	AuthorizedApp *authorizedapp.Config
-	Cleanup       *cleanup.Config
-	Export        *export.Config
-	Publish       *publish.Config
-	Database      *database.Config
-	FederationIn  *federationin.Config
-	BlobstoreType string `envconfig:"BLOBSTORE_TYPE" default:"CLOUD_STORAGE"`
+func (c *MonoConfig) AuthorizedAppConfig() *authorizedapp.Config {
+	return &c.AuthorizedApp
 }
 
-func (c *MonoConfig) DB() *database.Config { return c.Database }
-func (c *MonoConfig) KeyManager() bool     { return true }
-func (c *MonoConfig) BlobStorage() storage.BlobstoreConfig {
-	return storage.BlobstoreConfig{
-		BlobstoreType: storage.BlobstoreType(c.BlobstoreType),
-	}
+func (c *MonoConfig) BlobstoreConfig() *storage.Config {
+	return &c.Storage
 }
-func (c *MonoConfig) AuthorizedAppConfig() *authorizedapp.Config { return c.AuthorizedApp }
 
-func RunServer(ctx context.Context) (*MonoConfig, error) {
+func (c *MonoConfig) DatabaseConfig() *database.Config {
+	return &c.Database
+}
 
+func (c *MonoConfig) KeyManagerConfig() *signing.Config {
+	return &c.KeyManager
+}
+
+func (c *MonoConfig) SecretManagerConfig() *secrets.Config {
+	return &c.SecretManager
+}
+
+func RunServer(ctx context.Context) (*MonoConfig, error) {
 	logger := logging.FromContext(ctx)
 
 	var config MonoConfig
@@ -77,21 +92,21 @@ func RunServer(ctx context.Context) (*MonoConfig, error) {
 	mux := http.NewServeMux()
 
 	// Cleanup export
-	cleanupExport, err := cleanup.NewExportHandler(config.Cleanup, env)
+	cleanupExport, err := cleanup.NewExportHandler(&config.Cleanup, env)
 	if err != nil {
 		return nil, fmt.Errorf("cleanup.NewExportHandler: %w", err)
 	}
 	mux.Handle("/cleanup-export", cleanupExport)
 
 	// Cleanup exposure
-	cleanupExposure, err := cleanup.NewExposureHandler(config.Cleanup, env)
+	cleanupExposure, err := cleanup.NewExposureHandler(&config.Cleanup, env)
 	if err != nil {
 		return nil, fmt.Errorf("cleanup.NewExposureHandler: %w", err)
 	}
 	mux.Handle("/cleanup-exposure", cleanupExposure)
 
 	// Export
-	exportServer, err := export.NewServer(config.Export, env)
+	exportServer, err := export.NewServer(&config.Export, env)
 	if err != nil {
 		return nil, fmt.Errorf("export.NewServer: %w", err)
 	}
@@ -99,13 +114,13 @@ func RunServer(ctx context.Context) (*MonoConfig, error) {
 	mux.HandleFunc("/export/do-work", exportServer.WorkerHandler)
 
 	// Federation in
-	mux.Handle("/federation-in", federationin.NewHandler(env, config.FederationIn))
+	mux.Handle("/federation-in", federationin.NewHandler(env, &config.FederationIn))
 
 	// Federation out
 	// TODO: this is a grpc listener and requires a lot of setup.
 
 	// Publish
-	publishServer, err := publish.NewHandler(ctx, config.Publish, env)
+	publishServer, err := publish.NewHandler(ctx, &config.Publish, env)
 	if err != nil {
 		return nil, fmt.Errorf("publish.NewHandler: %w", err)
 	}
diff --git a/internal/observability/observability.go b/internal/observability/observability.go
index e04e8d7..3e1cca3 100644
--- a/internal/observability/observability.go
+++ b/internal/observability/observability.go
@@ -16,6 +16,7 @@ package observability
 
 import (
 	"os"
+	"strings"
 	"sync"
 
 	"contrib.go.opencensus.io/exporter/ocagent"
@@ -64,19 +65,19 @@ type traceAndViewExporter interface {
 }
 
 func exporter() traceAndViewExporter {
-	switch os.Getenv("OBSERVABILITY_EXPORTER") {
+	switch strings.ToUpper(os.Getenv("OBSERVABILITY_EXPORTER")) {
 	default:
 		// TODO: Add other trace and view exporters and break out of this.
 		return nil
 
-	case "ocagent":
+	case "OCAGENT":
 		oce, err := ocagent.NewExporter(ocagent.WithInsecure(), ocagent.WithAddress("localhost:55678"))
 		if err != nil {
 			panic(err)
 		}
 		return oce
 
-	case "stackdriver":
+	case "STACKDRIVER":
 		sde, err := stackdriver.NewExporter(stackdriver.Options{
 			ProjectID: os.Getenv("PROJECT_ID"),
 		})
diff --git a/internal/publish/config.go b/internal/publish/config.go
index f565930..ecca802 100644
--- a/internal/publish/config.go
+++ b/internal/publish/config.go
@@ -20,16 +20,22 @@ import (
 
 	"github.com/google/exposure-notifications-server/internal/authorizedapp"
 	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/secrets"
 	"github.com/google/exposure-notifications-server/internal/setup"
 )
 
 // Compile-time check to assert this config matches requirements.
 var _ setup.AuthorizedAppConfigProvider = (*Config)(nil)
-var _ setup.DBConfigProvider = (*Config)(nil)
+var _ setup.DatabaseConfigProvider = (*Config)(nil)
+var _ setup.SecretManagerConfigProvider = (*Config)(nil)
 
 // Config represents the configuration and associated environment variables for
 // the publish components.
 type Config struct {
+	AuthorizedApp authorizedapp.Config
+	Database      database.Config
+	SecretManager secrets.Config
+
 	Port               string        `envconfig:"PORT" default:"8080"`
 	MinRequestDuration time.Duration `envconfig:"TARGET_REQUEST_DURATION" default:"5s"`
 	MaxKeysOnPublish   int           `envconfig:"MAX_KEYS_ON_PUBLISH" default:"15"`
@@ -39,17 +45,16 @@ type Config struct {
 	// Flags for local development and testing.
 	DebugAPIResponses   bool `envconfig:"DEBUG_API_RESPONSES"`
 	DebugAllowRestOfDay bool `envconfig:"DEBUG_ALLOW_REST_OF_DAY"`
-
-	AuthorizedApp *authorizedapp.Config
-	Database      *database.Config
 }
 
-// AuthorizedApp returns the configuration for authorizedapp.
 func (c *Config) AuthorizedAppConfig() *authorizedapp.Config {
-	return c.AuthorizedApp
+	return &c.AuthorizedApp
+}
+
+func (c *Config) DatabaseConfig() *database.Config {
+	return &c.Database
 }
 
-// DB returns the configuration for the databse.
-func (c *Config) DB() *database.Config {
-	return c.Database
+func (c *Config) SecretManagerConfig() *secrets.Config {
+	return &c.SecretManager
 }
diff --git a/internal/publish/model/exposure_model.go b/internal/publish/model/exposure_model.go
index ff8467a..fac7a66 100644
--- a/internal/publish/model/exposure_model.go
+++ b/internal/publish/model/exposure_model.go
@@ -22,6 +22,7 @@ import (
 	"strings"
 	"time"
 
+	authapp "github.com/google/exposure-notifications-server/internal/authorizedapp/model"
 	"github.com/google/exposure-notifications-server/internal/base64util"
 )
 
@@ -54,6 +55,7 @@ const (
 // AppPackageName: The identifier for the mobile application.
 //  - Android: The App Package AppPackageName
 //  - iOS: The BundleID
+// Platform: "ios" or "android"
 // TransmissionRisk: An integer from 0-8 (inclusive) that represents
 //  the transmission risk for this publish.
 // Verification: The attestation payload for this request. (iOS or Android specific)
@@ -62,13 +64,23 @@ const (
 //  Note: This project doesn't directly include a diagnosis code verification System
 //        but does provide the ability to configure one in `serverevn.ServerEnv`
 type Publish struct {
-	Keys                      []ExposureKey `json:"temporaryExposureKeys"`
-	Regions                   []string      `json:"regions"`
-	AppPackageName            string        `json:"appPackageName"`
-	Platform                  string        `json:"platform"`
-	DeviceVerificationPayload string        `json:"deviceVerificationPayload"`
-	VerificationPayload       string        `json:"verificationPayload"`
-	Padding                   string        `json:"padding"`
+	Keys                []ExposureKey `json:"temporaryExposureKeys"`
+	Regions             []string      `json:"regions"`
+	AppPackageName      string        `json:"appPackageName"`
+	VerificationPayload string        `json:"verificationPayload"`
+	HMACKey             string        `json:"hmackey"`
+	Padding             string        `json:"padding"`
+
+	Platform                  string `json:"platform"`                  // DEPRECATED
+	DeviceVerificationPayload string `json:"deviceVerificationPayload"` // DEPRECATED
+}
+
+func (p *Publish) IsIOS() bool {
+	return strings.ToLower(p.Platform) == authapp.IosDevice
+}
+
+func (p *Publish) IsAndroid() bool {
+	return strings.ToLower(p.Platform) == authapp.AndroidDevice
 }
 
 // AndroidNonce returns the Android. This ensures that the data in the request
@@ -127,7 +139,7 @@ func (p *Publish) AndroidNonce() string {
 //   is configurable per installation.
 // IntervalCount must >= `minIntervalCount` and <= `maxIntervalCount`
 //   1 - 144 inclusive.
-// transmissionRisk must be >= 0 and <= 8
+// transmissionRisk must be >= 0 and <= 8.
 type ExposureKey struct {
 	Key              string `json:"key"`
 	IntervalNumber   int32  `json:"rollingStartNumber"`
@@ -137,7 +149,7 @@ type ExposureKey struct {
 
 // ExposureKeys represents a set of ExposureKey objects as input to
 // export file generation utility.
-// Keys: Required and must have length >= 1
+// Keys: Required and must have length >= 1.
 type ExposureKeys struct {
 	Keys []ExposureKey `json:"temporaryExposureKeys"`
 }
@@ -147,7 +159,7 @@ type ExposureKeys struct {
 // Exposure struct that doesn't have public fields and an
 // internal struct that does. Separate out the database model
 // from direct access.
-// Mark records as writable/nowritable - is exposure key encrypted
+// Mark records as writable/nowritable - is exposure key encrypted.
 type Exposure struct {
 	ExposureKey      []byte    `db:"exposure_key"`
 	TransmissionRisk int       `db:"transmission_risk"`
diff --git a/internal/publish/publish.go b/internal/publish/publish.go
index 9d3fca0..3b83440 100644
--- a/internal/publish/publish.go
+++ b/internal/publish/publish.go
@@ -17,6 +17,7 @@ package publish
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"net/http"
 	"time"
@@ -98,7 +99,7 @@ func (h *publishHandler) handleRequest(w http.ResponseWriter, r *http.Request) r
 		// Config loaded, but app with that name isn't registered. This can also
 		// happen if the app was recently registered but the cache hasn't been
 		// refreshed.
-		if err == authorizedapp.ErrAppNotFound {
+		if errors.Is(err, authorizedapp.ErrAppNotFound) {
 			message := fmt.Sprintf("unauthorized app: %v", data.AppPackageName)
 			logger.Error(message)
 			span.SetStatus(trace.Status{Code: trace.StatusCodeInternal, Message: message})
@@ -126,7 +127,11 @@ func (h *publishHandler) handleRequest(w http.ResponseWriter, r *http.Request) r
 		return response{status: http.StatusUnauthorized, message: message, metric: "publish-region-not-authorized", count: 1}
 	}
 
-	if appConfig.IsIOS() {
+	// If an AuthorizedApp is configured for a single platform only, we can ignore
+	// the incoming platform field on the Publish request.
+	// If the AuthorizedApp is dual platform, then let the in coming Publish.Platform
+	// decide which attestation validation is used.
+	if appConfig.IsIOS() || (appConfig.IsDualPlatform() && data.IsIOS()) {
 		span.AddAttributes(trace.StringAttribute("app_platform", "ios"))
 		if appConfig.DeviceCheckDisabled {
 			logger.Errorf("skipping DeviceCheck for %v (disabled)", data.AppPackageName)
@@ -137,7 +142,7 @@ func (h *publishHandler) handleRequest(w http.ResponseWriter, r *http.Request) r
 			span.SetStatus(trace.Status{Code: trace.StatusCodePermissionDenied, Message: message})
 			return response{status: http.StatusUnauthorized, message: message, metric: "publish-devicecheck-invalid", count: 1}
 		}
-	} else if appConfig.IsAndroid() {
+	} else if appConfig.IsAndroid() || (appConfig.IsDualPlatform() && data.IsAndroid()) {
 		span.AddAttributes(trace.StringAttribute("app_platform", "android"))
 		if appConfig.SafetyNetDisabled {
 			message := fmt.Sprintf("skipping SafetyNet for %v (disabled)", data.AppPackageName)
@@ -153,8 +158,8 @@ func (h *publishHandler) handleRequest(w http.ResponseWriter, r *http.Request) r
 	} else {
 		message := fmt.Sprintf("invalid AuthorizedApp config %v: invalid platform %v", data.AppPackageName, data.Platform)
 		logger.Error(message)
-		span.SetStatus(trace.Status{Code: trace.StatusCodeInternal, Message: message})
-		return response{status: http.StatusInternalServerError, message: message, metric: "publish-authorizedapp-missing-platform", count: 1}
+		span.SetStatus(trace.Status{Code: trace.StatusCodeInvalidArgument, Message: message})
+		return response{status: http.StatusBadRequest, message: message, metric: "publish-authorizedapp-missing-platform", count: 1}
 	}
 
 	batchTime := time.Now()
@@ -199,7 +204,7 @@ func (h *publishHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	// Handle success. If debug enabled, write the message in the response.
 	if response.status == http.StatusOK {
 		if h.config.DebugAPIResponses {
-			w.Write([]byte(response.message))
+			fmt.Fprint(w, response.message)
 		} else {
 			w.WriteHeader(http.StatusOK)
 		}
@@ -210,7 +215,7 @@ func (h *publishHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	// out the error and status.
 	if h.config.DebugAPIResponses || response.errorInProd {
 		w.WriteHeader(response.status)
-		w.Write([]byte(response.message))
+		fmt.Fprint(w, response.message)
 		return
 	}
 
diff --git a/internal/secrets/azure_keyvault.go b/internal/secrets/azure_keyvault.go
index 1307ea0..4378106 100644
--- a/internal/secrets/azure_keyvault.go
+++ b/internal/secrets/azure_keyvault.go
@@ -80,5 +80,5 @@ func (kv *AzureKeyVault) GetSecretValue(ctx context.Context, name string) (strin
 	if result.Value == nil {
 		return "", fmt.Errorf("found secret %v, but value was nil", name)
 	}
-	return string(*result.Value), nil
+	return *result.Value, nil
 }
diff --git a/internal/secrets/cacher.go b/internal/secrets/cacher.go
index eec019d..b5d18f8 100644
--- a/internal/secrets/cacher.go
+++ b/internal/secrets/cacher.go
@@ -48,16 +48,16 @@ func NewCacher(ctx context.Context, f SecretManagerFunc, ttl time.Duration) (Sec
 		return nil, fmt.Errorf("cacher: %w", err)
 	}
 
-	return WrapCacher(ctx, sm, ttl)
+	return WrapCacher(ctx, sm, ttl), nil
 }
 
 // WrapCacher wraps an existing SecretManager with caching.
-func WrapCacher(ctx context.Context, sm SecretManager, ttl time.Duration) (SecretManager, error) {
+func WrapCacher(ctx context.Context, sm SecretManager, ttl time.Duration) SecretManager {
 	return &Cacher{
 		sm:    sm,
 		ttl:   ttl,
 		cache: make(map[string]*cachedItem),
-	}, nil
+	}
 }
 
 // GetSecretValue implements the SecretManager interface, but caches values and
diff --git a/internal/secrets/gcp_secret_manager.go b/internal/secrets/google_secret_manager.go
similarity index 78%
rename from internal/secrets/gcp_secret_manager.go
rename to internal/secrets/google_secret_manager.go
index 2e969a6..2307b37 100644
--- a/internal/secrets/gcp_secret_manager.go
+++ b/internal/secrets/google_secret_manager.go
@@ -23,21 +23,21 @@ import (
 )
 
 // Compile-time check to verify implements interface.
-var _ SecretManager = (*GCPSecretManager)(nil)
+var _ SecretManager = (*GoogleSecretManager)(nil)
 
-// GCPSecretManager implements SecretManager.
-type GCPSecretManager struct {
+// GoogleSecretManager implements SecretManager.
+type GoogleSecretManager struct {
 	client *secretmanager.Client
 }
 
-// NewGCPSecretManager creates a new secret manager for GCP.
-func NewGCPSecretManager(ctx context.Context) (SecretManager, error) {
+// NewGoogleSecretManager creates a new secret manager for GCP.
+func NewGoogleSecretManager(ctx context.Context) (SecretManager, error) {
 	client, err := secretmanager.NewClient(ctx)
 	if err != nil {
 		return nil, fmt.Errorf("secretmanager.NewClient: %w", err)
 	}
 
-	sm := &GCPSecretManager{
+	sm := &GoogleSecretManager{
 		client: client,
 	}
 
@@ -48,7 +48,7 @@ func NewGCPSecretManager(ctx context.Context) (SecretManager, error) {
 // of the format:
 //
 //     projects/my-project/secrets/my-secret/versions/123
-func (sm *GCPSecretManager) GetSecretValue(ctx context.Context, name string) (string, error) {
+func (sm *GoogleSecretManager) GetSecretValue(ctx context.Context, name string) (string, error) {
 	result, err := sm.client.AccessSecretVersion(ctx, &secretmanagerpb.AccessSecretVersionRequest{
 		Name: name,
 	})
diff --git a/internal/secrets/secrets.go b/internal/secrets/secrets.go
index 8ea4d51..ff74e3d 100644
--- a/internal/secrets/secrets.go
+++ b/internal/secrets/secrets.go
@@ -16,7 +16,26 @@
 // Allows for a different implementation to be bound within the servernv.ServeEnv
 package secrets
 
-import "context"
+import (
+	"context"
+	"fmt"
+	"time"
+)
+
+// SecretManagerType represents a type of secret manager.
+type SecretManagerType string
+
+const (
+	SecretManagerTypeAzureKeyVault        SecretManagerType = "AZURE_KEY_VAULT"
+	SecretManagerTypeGoogleSecretManager  SecretManagerType = "GOOGLE_SECRET_MANAGER"
+	SecretManagerTypeGoogleHashiCorpVault SecretManagerType = "HASHICORP_VAULT"
+)
+
+// Config represents the config for a secret manager.
+type Config struct {
+	SecretManagerType SecretManagerType `envconfig:"SECRET_MANAGER" default:"GOOGLE_SECRET_MANAGER"`
+	SecretCacheTTL    time.Duration     `envconfig:"SECRET_CACHE_TTL" default:"5m"`
+}
 
 // SecretManager defines the minimum shared functionality for a secret manager
 // used by this application.
@@ -26,3 +45,18 @@ type SecretManager interface {
 
 // SecretManagerFunc is a func that returns a secret manager or error.
 type SecretManagerFunc func(ctx context.Context) (SecretManager, error)
+
+// SecretManagerFor returns the secret manager for the given type, or an error
+// if one does not exist.
+func SecretManagerFor(ctx context.Context, typ SecretManagerType) (SecretManager, error) {
+	switch typ {
+	case SecretManagerTypeAzureKeyVault:
+		return NewAzureKeyVault(ctx)
+	case SecretManagerTypeGoogleSecretManager:
+		return NewGoogleSecretManager(ctx)
+	case SecretManagerTypeGoogleHashiCorpVault:
+		return NewHashiCorpVault(ctx)
+	}
+
+	return nil, fmt.Errorf("unknown secret manager type: %v", typ)
+}
diff --git a/internal/setup/setup.go b/internal/setup/setup.go
index 82b1040..3823331 100644
--- a/internal/setup/setup.go
+++ b/internal/setup/setup.go
@@ -17,7 +17,6 @@ package setup
 import (
 	"context"
 	"fmt"
-	"time"
 
 	"github.com/google/exposure-notifications-server/internal/authorizedapp"
 	"github.com/google/exposure-notifications-server/internal/database"
@@ -30,10 +29,16 @@ import (
 	"github.com/google/exposure-notifications-server/internal/storage"
 )
 
-// DBConfigProvider ensures that the environment config can provide a DB config.
+// BlobstoreConfigProvider provides the information about current storage
+// configuration.
+type BlobstoreConfigProvider interface {
+	BlobstoreConfig() *storage.Config
+}
+
+// DatabaseConfigProvider ensures that the environment config can provide a DB config.
 // All binaries in this application connect to the database via the same method.
-type DBConfigProvider interface {
-	DB() *database.Config
+type DatabaseConfigProvider interface {
+	DatabaseConfig() *database.Config
 }
 
 // AuthorizedAppConfigProvider signals that the config provided knows how to
@@ -42,33 +47,57 @@ type AuthorizedAppConfigProvider interface {
 	AuthorizedAppConfig() *authorizedapp.Config
 }
 
-// KeyManagerProvider is a marker interface indicating the KeyManagerProvider should be installed.
-type KeyManagerProvider interface {
-	KeyManager() bool
+// KeyManagerConfigProvider is a marker interface indicating the key manager
+// should be installed.
+type KeyManagerConfigProvider interface {
+	KeyManagerConfig() *signing.Config
 }
 
-// BlobStorageConfigProvider provides the information about current Blobstore configuration.
-type BlobStorageConfigProvider interface {
-	BlobStorage() storage.BlobstoreConfig
+// SecretManagerConfigProvider signals that the config knows how to configure a
+// secret manager.
+type SecretManagerConfigProvider interface {
+	SecretManagerConfig() *secrets.Config
 }
 
 // Function returned from setup to be deferred until the caller exits.
 type Defer func()
 
 // Setup runs common initialization code for all servers.
-func Setup(ctx context.Context, config DBConfigProvider) (*serverenv.ServerEnv, Defer, error) {
+func Setup(ctx context.Context, config DatabaseConfigProvider) (*serverenv.ServerEnv, Defer, error) {
 	logger := logging.FromContext(ctx)
 
-	// Can be changed with a different secret manager interface.
-	// TODO(mikehelmick): Make this extensible to other providers.
-	// TODO(sethvargo): Make TTL configurable.
-	sm, err := secrets.NewCacher(ctx, secrets.NewGCPSecretManager, 5*time.Minute)
-	if err != nil {
-		return nil, nil, fmt.Errorf("unable to connect to secret manager: %v", err)
+	// Load the secret manager - this needs to be loaded first because other
+	// processors may require access to secrets.
+	var sm secrets.SecretManager
+	if provider, ok := config.(SecretManagerConfigProvider); ok {
+		// The environment configuration defines which secret manager to use, so we
+		// need to process the envconfig in here. Once we know which secret manager
+		// to use, we can load the secret manager and then re-process (later) any
+		// secret:// references.
+		//
+		// NOTE: it is not possible to specify which secret manager to use via a
+		// secret:// reference. This configuration option must always be the
+		// plaintext string.
+		smConfig := provider.SecretManagerConfig()
+		if err := envconfig.Process(ctx, smConfig, nil); err != nil {
+			return nil, nil, fmt.Errorf("unable to process secret manager environment: %w", err)
+		}
+
+		var err error
+		sm, err = secrets.SecretManagerFor(ctx, smConfig.SecretManagerType)
+		if err != nil {
+			return nil, nil, fmt.Errorf("unable to connect to secret manager: %w", err)
+		}
+
+		// Enable caching, if provided.
+		if ttl := smConfig.SecretCacheTTL; ttl > 0 {
+			sm = secrets.WrapCacher(ctx, sm, ttl)
+		}
 	}
 
+	// Process first round of environment variables.
 	if err := envconfig.Process(ctx, config, sm); err != nil {
-		return nil, nil, fmt.Errorf("error loading environment variables: %v", err)
+		return nil, nil, fmt.Errorf("error loading environment variables: %w", err)
 	}
 	logger.Infof("Effective environment variables: %+v", config)
 
@@ -78,18 +107,20 @@ func Setup(ctx context.Context, config DBConfigProvider) (*serverenv.ServerEnv,
 		serverenv.WithMetricsExporter(metrics.NewLogsBasedFromContext),
 	}
 
-	// TODO(mikehelmick): Make this extensible to other providers.
-	if _, ok := config.(KeyManagerProvider); ok {
-		km, err := signing.NewGCPKMS(ctx)
+	// Configure key management for signing.
+	if provider, ok := config.(KeyManagerConfigProvider); ok {
+		kmConfig := provider.KeyManagerConfig()
+		km, err := signing.KeyManagerFor(ctx, kmConfig.KeyManagerType)
 		if err != nil {
 			return nil, nil, fmt.Errorf("unable to connect to key manager: %w", err)
 		}
 		opts = append(opts, serverenv.WithKeyManager(km))
 	}
 
-	if _, ok := config.(BlobStorageConfigProvider); ok {
-		provider := config.(BlobStorageConfigProvider)
-		blobStore, err := storage.CreateBlobstore(ctx, provider.BlobStorage())
+	// Configure blob storage.
+	if provider, ok := config.(BlobstoreConfigProvider); ok {
+		bsConfig := provider.BlobstoreConfig()
+		blobStore, err := storage.BlobstoreFor(ctx, bsConfig.BlobstoreType)
 		if err != nil {
 			return nil, nil, fmt.Errorf("unable to connect to storage system: %v", err)
 		}
@@ -98,13 +129,13 @@ func Setup(ctx context.Context, config DBConfigProvider) (*serverenv.ServerEnv,
 	}
 
 	// Setup the database connection.
-	db, err := database.NewFromEnv(ctx, config.DB())
+	db, err := database.NewFromEnv(ctx, config.DatabaseConfig())
 	if err != nil {
 		return nil, nil, fmt.Errorf("unable to connect to database: %v", err)
 	}
 	{
 		// Log the database config, but omit the password field.
-		redactedDB := config.DB()
+		redactedDB := config.DatabaseConfig()
 		redactedDB.Password = "<hidden>"
 		logger.Infof("Effective DB config: %+v", redactedDB)
 	}
diff --git a/internal/signing/gcpkms.go b/internal/signing/google_cloud_kms.go
similarity index 74%
rename from internal/signing/gcpkms.go
rename to internal/signing/google_cloud_kms.go
index a300879..643df42 100644
--- a/internal/signing/gcpkms.go
+++ b/internal/signing/google_cloud_kms.go
@@ -23,23 +23,23 @@ import (
 )
 
 // Compile-time check to verify implements interface.
-var _ KeyManager = (*GCPKMS)(nil)
+var _ KeyManager = (*GoogleCloudKMS)(nil)
 
-// GCPKMS implements the signing.KeyManager interface and can be used to sign
+// GoogleCloudKMS implements the signing.KeyManager interface and can be used to sign
 // export files.
-type GCPKMS struct {
+type GoogleCloudKMS struct {
 	client *kms.KeyManagementClient
 }
 
-func NewGCPKMS(ctx context.Context) (KeyManager, error) {
+func NewGoogleCloudKMS(ctx context.Context) (KeyManager, error) {
 	client, err := kms.NewKeyManagementClient(ctx)
 	if err != nil {
 		return nil, err
 	}
-	return &GCPKMS{client}, nil
+	return &GoogleCloudKMS{client}, nil
 }
 
-func (kms *GCPKMS) NewSigner(ctx context.Context, keyID string) (crypto.Signer, error) {
+func (kms *GoogleCloudKMS) NewSigner(ctx context.Context, keyID string) (crypto.Signer, error) {
 	signer, err := gcpkms.NewSigner(ctx, kms.client, keyID)
 	if err != nil {
 		return nil, err
diff --git a/internal/signing/hashicorp_vault.go b/internal/signing/hashicorp_vault.go
index f64f8ca..0871031 100644
--- a/internal/signing/hashicorp_vault.go
+++ b/internal/signing/hashicorp_vault.go
@@ -77,7 +77,7 @@ type HashiCorpVaultSigner struct {
 	publicKey crypto.PublicKey
 }
 
-// NewHashiCorpVaultSigner creates a new signing interface compatiable with
+// NewHashiCorpVaultSigner creates a new signing interface compatible with
 // HashiCorp Vault's transit backend. The key name and key version are required.
 func NewHashiCorpVaultSigner(ctx context.Context, client *vaultapi.Client, name, version string) (*HashiCorpVaultSigner, error) {
 	if client == nil {
diff --git a/internal/signing/signing.go b/internal/signing/signing.go
index eaca638..b359d72 100644
--- a/internal/signing/signing.go
+++ b/internal/signing/signing.go
@@ -18,11 +18,37 @@ package signing
 import (
 	"context"
 	"crypto"
+	"fmt"
 )
 
+// KeyManagerType defines a specific key manager.
+type KeyManagerType string
+
+const (
+	KeyManagerTypeGoogleCloudKMS KeyManagerType = "GOOGLE_CLOUD_KMS"
+	KeyManagerTypeHashiCorpVault KeyManagerType = "HASHICORP_VAULT"
+)
+
+// Config defines configuration.
+type Config struct {
+	KeyManagerType KeyManagerType `envconfig:"KEY_MANAGER" default:"GOOGLE_CLOUD_KMS"`
+}
+
 // KeyManager defines the interface for working with a KMS system that
 // is able to sign bytes using PKI.
-// KeyManager implementations must be able to return a crypto.Signer
+// KeyManager implementations must be able to return a crypto.Signer.
 type KeyManager interface {
 	NewSigner(ctx context.Context, keyID string) (crypto.Signer, error)
 }
+
+// KeyManagerFor returns the appropriate key manager for the given type.
+func KeyManagerFor(ctx context.Context, typ KeyManagerType) (KeyManager, error) {
+	switch typ {
+	case KeyManagerTypeGoogleCloudKMS:
+		return NewGoogleCloudKMS(ctx)
+	case KeyManagerTypeHashiCorpVault:
+		return NewHashiCorpVault(ctx)
+	}
+
+	return nil, fmt.Errorf("unknown key manager type: %v", typ)
+}
diff --git a/internal/storage/filesystem_storage.go b/internal/storage/filesystem_storage.go
index 92c1ba4..9088bab 100644
--- a/internal/storage/filesystem_storage.go
+++ b/internal/storage/filesystem_storage.go
@@ -38,7 +38,7 @@ func NewFilesystemStorage(ctx context.Context) (Blobstore, error) {
 
 // CreateObject creates a new object on the filesystem or overwrites an existing
 // one.
-func (s *FilesystemStorage) CreateObject(ctx context.Context, folder, filename string, contents []byte) error {
+func (s *FilesystemStorage) CreateObject(ctx context.Context, folder, filename string, contents []byte, cacheable bool) error {
 	pth := filepath.Join(folder, filename)
 	if err := ioutil.WriteFile(pth, contents, 0644); err != nil {
 		return fmt.Errorf("failed to create object: %w", err)
diff --git a/internal/storage/google_cloud_storage.go b/internal/storage/google_cloud_storage.go
index 9b73685..a7d9bbd 100644
--- a/internal/storage/google_cloud_storage.go
+++ b/internal/storage/google_cloud_storage.go
@@ -33,7 +33,7 @@ type GoogleCloudStorage struct {
 }
 
 // NewGoogleCloudStorage creates a Google Cloud Storage Client, suitable
-// for use with serverenv.ServerEnv
+// for use with serverenv.ServerEnv.
 func NewGoogleCloudStorage(ctx context.Context) (Blobstore, error) {
 	client, err := storage.NewClient(ctx)
 	if err != nil {
@@ -43,8 +43,13 @@ func NewGoogleCloudStorage(ctx context.Context) (Blobstore, error) {
 }
 
 // CreateObject creates a new cloud storage object or overwrites an existing one.
-func (gcs *GoogleCloudStorage) CreateObject(ctx context.Context, bucket, objectName string, contents []byte) error {
+func (gcs *GoogleCloudStorage) CreateObject(ctx context.Context, bucket, objectName string, contents []byte, cacheable bool) error {
 	wc := gcs.client.Bucket(bucket).Object(objectName).NewWriter(ctx)
+	if !cacheable {
+		wc.Metadata = map[string]string{
+			"Cache-Control": "no-cache, max-age=0",
+		}
+	}
 	if _, err := wc.Write(contents); err != nil {
 		return fmt.Errorf("storage.Writer.Write: %w", err)
 	}
diff --git a/internal/storage/noop_storage.go b/internal/storage/noop_storage.go
new file mode 100644
index 0000000..b716671
--- /dev/null
+++ b/internal/storage/noop_storage.go
@@ -0,0 +1,35 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package storage
+
+import "context"
+
+// Compile-time check to verify implements.
+var _ Blobstore = (*NoopBlobstore)(nil)
+
+// NoopBlobstore is a blobstore that does nothing.
+type NoopBlobstore struct{}
+
+func NewNoopBlobstore(ctx context.Context) (Blobstore, error) {
+	return &NoopBlobstore{}, nil
+}
+
+func (s *NoopBlobstore) CreateObject(ctx context.Context, folder, filename string, contents []byte, cacheable bool) error {
+	return nil
+}
+
+func (s *NoopBlobstore) DeleteObject(ctx context.Context, folder, filename string) error {
+	return nil
+}
diff --git a/internal/storage/storage.go b/internal/storage/storage.go
index 1e8769f..05377d4 100644
--- a/internal/storage/storage.go
+++ b/internal/storage/storage.go
@@ -18,63 +18,42 @@ package storage
 import (
 	"context"
 	"fmt"
-
-	"github.com/google/exposure-notifications-server/internal/logging"
 )
 
-// Identifies the type of Blobestore to use
+// BlobstoreType defines a specific blobstore.
 type BlobstoreType string
 
 const (
-	None          BlobstoreType = "NONE"
-	Cloud_storage BlobstoreType = "CLOUD_STORAGE"
-	Filesystem    BlobstoreType = "FILESYSTEM"
+	BlobstoreTypeGoogleCloudStorage BlobstoreType = "GOOGLE_CLOUD_STORAGE"
+	BlobstoreTypeFilesystem         BlobstoreType = "FILESYSTEM"
+	BlobstoreTypeNoop               BlobstoreType = "NOOP"
 )
 
-// Blobstore Configuration
-type BlobstoreConfig struct {
-	BlobstoreType BlobstoreType
+// Config defines the configuration for a blobstore.
+type Config struct {
+	BlobstoreType BlobstoreType `envconfig:"BLOBSTORE" default:"GOOGLE_CLOUD_STORAGE"`
 }
 
 // Blobstore defines the minimum interface for a blob storage system.
 type Blobstore interface {
 	// CreateObject creates or overwrites an object in the storage system.
-	CreateObject(ctx context.Context, bucket, objectName string, contents []byte) error
+	CreateObject(ctx context.Context, bucket, objectName string, contents []byte, cacheable bool) error
 
 	// DeleteObject deltes an object or does nothing if the object doesn't exist.
 	DeleteObject(ctx context.Context, bucket, objectName string) error
 }
 
-// Blobstore that does nothing.
-type NoopBlobstore struct{}
-
-func NewNoopBlobstore(ctx context.Context) (Blobstore, error) {
-	return &NoopBlobstore{}, nil
-}
-
-// No op.
-func (s *NoopBlobstore) CreateObject(ctx context.Context, folder, filename string, contents []byte) error {
-	return nil
-}
-
-// No op.
-func (s *NoopBlobstore) DeleteObject(ctx context.Context, folder, filename string) error {
-	return nil
-}
-
-// Creates a new BlobstoreFactory based on the provided BlobstoreType.
-func CreateBlobstore(ctx context.Context, config BlobstoreConfig) (Blobstore, error) {
-	logger := logging.FromContext(ctx)
-	logger.Infof("BlobstoreType is set up to %v", config.BlobstoreType)
-
-	switch config.BlobstoreType {
-	case Cloud_storage:
+// BlobstoreFor returns the blob store for the given type, or an error if one
+// does not exist.
+func BlobstoreFor(ctx context.Context, typ BlobstoreType) (Blobstore, error) {
+	switch typ {
+	case BlobstoreTypeGoogleCloudStorage:
 		return NewGoogleCloudStorage(ctx)
-	case Filesystem:
+	case BlobstoreTypeFilesystem:
 		return NewFilesystemStorage(ctx)
-	case None:
+	case BlobstoreTypeNoop:
 		return NewNoopBlobstore(ctx)
 	default:
-		return nil, fmt.Errorf("unknown BlobstoreType: %v", config.BlobstoreType)
+		return nil, fmt.Errorf("unknown blob store: %v", typ)
 	}
 }
diff --git a/internal/util/generate.go b/internal/util/generate.go
index 0653f2d..f8ec5ff 100644
--- a/internal/util/generate.go
+++ b/internal/util/generate.go
@@ -48,7 +48,7 @@ func RandomInt(maxValue int) (int, error) {
 	return int(n.Int64()), nil
 }
 
-// RandomIntWithMin is inclusive, [min:max]
+// RandomIntWithMin is inclusive, [min:max].
 func RandomIntWithMin(min, max int) (int, error) {
 	n, err := rand.Int(rand.Reader, big.NewInt(int64(max-min+1)))
 	if err != nil {
diff --git a/internal/verification/database/health_authority_db.go b/internal/verification/database/health_authority_db.go
new file mode 100644
index 0000000..7937e28
--- /dev/null
+++ b/internal/verification/database/health_authority_db.go
@@ -0,0 +1,167 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package database
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"time"
+
+	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/verification/model"
+
+	pgx "github.com/jackc/pgx/v4"
+)
+
+// HealthAuthorityDB allows for opreations against authorized health authorities
+// for diagnosis signature verification.
+type HealthAuthorityDB struct {
+	db *database.DB
+}
+
+// New creates a HealthAuthorityDB attached to a specific database driver.
+func New(db *database.DB) *HealthAuthorityDB {
+	return &HealthAuthorityDB{db}
+}
+
+// AddHealthAuthority inserts a new HealthAuthority record into the database.
+func (db *HealthAuthorityDB) AddHealthAuthority(ctx context.Context, ha *model.HealthAuthority) error {
+	if ha == nil {
+		return errors.New("provided HealthAuthority cannot be nil")
+	}
+	if len(ha.Keys) != 0 {
+		return fmt.Errorf("unable to insert health authority with keys, attach keys later")
+	}
+	if err := ha.Validate(); err != nil {
+		return err
+	}
+
+	return db.db.InTx(ctx, pgx.Serializable, func(tx pgx.Tx) error {
+		row := tx.QueryRow(ctx, `
+			INSERT INTO
+				HealthAuthority
+				(iss, aud, name)
+			VALUES
+				($1, $2, $3)
+			RETURNING id
+			`, ha.Issuer, ha.Audience, ha.Name)
+		if err := row.Scan(&ha.ID); err != nil {
+			return fmt.Errorf("inserting healthauthority: %w", err)
+		}
+		return nil
+	})
+}
+
+// GetHealthAuthority retrieves a HealthAuthority record by the issuer name.
+func (db *HealthAuthorityDB) GetHealthAuthority(ctx context.Context, issuer string) (*model.HealthAuthority, error) {
+	conn, err := db.db.Pool.Acquire(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("acquiring connection: %w", err)
+	}
+	defer conn.Release()
+
+	row := conn.QueryRow(ctx, `
+		SELECT
+			id, iss, aud, name
+		FROM
+			HealthAuthority
+		WHERE
+			iss = $1`, issuer)
+
+	var ha model.HealthAuthority
+	if err := row.Scan(&ha.ID, &ha.Issuer, &ha.Audience, &ha.Name); err != nil {
+		return nil, err
+	}
+
+	haks, err := db.GetHealthAuthorityKeys(ctx, &ha)
+	if err != nil {
+		return nil, err
+	}
+	ha.Keys = haks
+
+	return &ha, nil
+}
+
+func (db *HealthAuthorityDB) AddHealthAuthorityKey(ctx context.Context, ha *model.HealthAuthority, hak *model.HealthAuthorityKey) error {
+	if ha == nil {
+		return errors.New("provided HealthAuthority cannot be nil")
+	}
+	if hak == nil {
+		return errors.New("provided HealthAuthorityKey cannot be nil")
+	}
+
+	if err := hak.Validate(); err != nil {
+		return err
+	}
+	if ha.ID == 0 {
+		return errors.New("invalid health authority ID, must be non zero")
+	}
+
+	hak.AuthorityID = ha.ID
+	thru := db.db.NullableTime(hak.Thru)
+	return db.db.InTx(ctx, pgx.Serializable, func(tx pgx.Tx) error {
+		result, err := tx.Exec(ctx, `
+			INSERT INTO
+				HealthAuthorityKey
+				(health_authority_id, version, from_timestamp, thru_timestamp, public_key)
+			VALUES
+				($1, $2, $3, $4, $5)
+			`, hak.AuthorityID, hak.Version, hak.From, thru, hak.PublicKeyPEM)
+		if err != nil {
+			return fmt.Errorf("inserting healthauthoritykey: %w", err)
+		}
+		if result.RowsAffected() != 1 {
+			return fmt.Errorf("no rows inserted")
+		}
+		return nil
+	})
+}
+
+func (db *HealthAuthorityDB) GetHealthAuthorityKeys(ctx context.Context, ha *model.HealthAuthority) ([]*model.HealthAuthorityKey, error) {
+	conn, err := db.db.Pool.Acquire(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("acquiring connection: %w", err)
+	}
+	defer conn.Release()
+
+	rows, err := conn.Query(ctx, `
+		SELECT
+		  health_authority_id, version, from_timestamp, thru_timestamp, public_key
+		FROM
+		  HealthAuthorityKey
+		WHERE
+		  health_authority_id = $1`, ha.ID)
+	if err != nil {
+		return nil, err
+	}
+
+	var haks []*model.HealthAuthorityKey
+	for rows.Next() {
+		if rows.Err() != nil {
+			return nil, rows.Err()
+		}
+		var hak model.HealthAuthorityKey
+		var thru *time.Time
+		if err := rows.Scan(&hak.AuthorityID, &hak.Version, &hak.From, &thru, &hak.PublicKeyPEM); err != nil {
+			return nil, err
+		}
+		if thru != nil {
+			hak.Thru = *thru
+		}
+		haks = append(haks, &hak)
+	}
+	return haks, nil
+}
diff --git a/internal/verification/model/health_authority.go b/internal/verification/model/health_authority.go
new file mode 100644
index 0000000..2899c2a
--- /dev/null
+++ b/internal/verification/model/health_authority.go
@@ -0,0 +1,95 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package model
+
+import (
+	"crypto/ecdsa"
+	"crypto/x509"
+	"encoding/pem"
+	"errors"
+	"fmt"
+	"time"
+)
+
+// HealthAuthority represents a public health authority that is authorized to
+// issue diagnosis verification certificates accepted by this server.
+type HealthAuthority struct {
+	ID       int64
+	Issuer   string
+	Audience string
+	Name     string
+	Keys     []*HealthAuthorityKey
+}
+
+// Validate returns an error if the HealthAuthority struct is not valid.
+func (ha *HealthAuthority) Validate() error {
+	if ha.Issuer == "" {
+		return errors.New("issuer cannot be empty")
+	}
+	if ha.Audience == "" {
+		return errors.New("audience cannot be empty")
+	}
+	if ha.Name == "" {
+		return errors.New("name cannot be empty")
+	}
+	return nil
+}
+
+// HealthAuthorityKey represents a public key version for a given health authority.
+type HealthAuthorityKey struct {
+	AuthorityID  int64
+	Version      string
+	From         time.Time
+	Thru         time.Time
+	PublicKeyPEM string
+}
+
+// Validate returns an error if the HealthAuthorityKey is not valid.
+func (k *HealthAuthorityKey) Validate() error {
+	if _, err := k.PublicKey(); err != nil {
+		return fmt.Errorf("invalid public key PEM block: %w", err)
+	}
+	return nil
+}
+
+// IsValid returns true if the key is valid based on the current time.
+func (k *HealthAuthorityKey) IsValid() bool {
+	return k.IsValidAt(time.Now())
+}
+
+// IsValidAt returns true if the key is valid at a specific point in time.
+func (k *HealthAuthorityKey) IsValidAt(t time.Time) bool {
+	return t.After(k.From) && (k.Thru.IsZero() || k.Thru.After(t))
+}
+
+// PublicKey decodes the PublicKeyPEM text and returns the `*ecdsa.PublicKey`
+// This system only supports verifying ECDSA JWTs, `alg: ES256`.
+func (k *HealthAuthorityKey) PublicKey() (*ecdsa.PublicKey, error) {
+	block, _ := pem.Decode([]byte(k.PublicKeyPEM))
+	if block == nil || block.Type != "PUBLIC KEY" {
+		return nil, errors.New("unable to decode PEM block containing PUBLIC KEY")
+	}
+	pub, err := x509.ParsePKIXPublicKey(block.Bytes)
+	if err != nil {
+		return nil, fmt.Errorf("x509.ParsePKIXPublicKey: %w", err)
+	}
+
+	switch typ := pub.(type) {
+	case *ecdsa.PublicKey:
+		return typ, nil
+	default:
+		return nil, fmt.Errorf("unsupported public key type: %T", typ)
+	}
+}
diff --git a/internal/verification/phaverify.go b/internal/verification/phaverify.go
new file mode 100644
index 0000000..0650974
--- /dev/null
+++ b/internal/verification/phaverify.go
@@ -0,0 +1,120 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package verification provides the ability to verify the diagnosis certificates
+// (JWTs) coming from public health authorities that are responsible for verifying
+// diagnosis pin codes and ceritfying the TEKs.
+package verification
+
+import (
+	"context"
+	"crypto/hmac"
+	"fmt"
+
+	aamodel "github.com/google/exposure-notifications-server/internal/authorizedapp/model"
+	"github.com/google/exposure-notifications-server/internal/base64util"
+	pubmodel "github.com/google/exposure-notifications-server/internal/publish/model"
+	"github.com/google/exposure-notifications-server/internal/verification/database"
+
+	verifyapi "github.com/google/exposure-notifications-server/pkg/api/v1alpha1"
+	utils "github.com/google/exposure-notifications-server/pkg/verification"
+
+	"github.com/dgrijalva/jwt-go"
+)
+
+// Verifier can be used to verify public health authority diagnosis verification certificates.
+type Verifier struct {
+	db *database.HealthAuthorityDB
+}
+
+// New creates a new verifier, based on this DB handle.
+func New(db *database.HealthAuthorityDB) *Verifier {
+	return &Verifier{db}
+}
+
+// VerifyDiagnosisCertificate accepts a publish request (from which is extracts the JWT),
+// fully verifies the JWT and signture against what the passed in authorrized app is allowed
+// to use. Returns any transmission risk overrides if they are present.
+func (v *Verifier) VerifyDiagnosisCertificate(ctx context.Context, authApp *aamodel.AuthorizedApp, publish *pubmodel.Publish) (verifyapi.TransmissionRiskVector, error) {
+	// These get assigned during the ParseWithClaims closure.
+	var healthAuthorityID int64
+	var claims *verifyapi.VerificationClaims
+
+	// Unpack JWT so we can determine issuer and key version.
+	token, err := jwt.ParseWithClaims(publish.VerificationPayload, &verifyapi.VerificationClaims{}, func(token *jwt.Token) (interface{}, error) {
+		if method, ok := token.Method.(*jwt.SigningMethodECDSA); !ok || method.Name != jwt.SigningMethodES256.Name {
+			return nil, fmt.Errorf("unsupported signing method, must be %v", jwt.SigningMethodES256.Name)
+		}
+
+		var ok bool
+		claims, ok = token.Claims.(*verifyapi.VerificationClaims)
+		if !ok {
+			return nil, fmt.Errorf("does not contain expected claim set")
+		}
+
+		// Based on issuer, load the key versions.
+		ha, err := v.db.GetHealthAuthority(ctx, claims.Issuer)
+		if err != nil {
+			return nil, fmt.Errorf("error looking up issuer: %v : %w", claims.Issuer, err)
+		}
+
+		// Find a key version.
+		for _, hak := range ha.Keys {
+			// Key version matches and the key is valid based on the current time.
+			if hak.Version == claims.KeyVersion && hak.IsValid() {
+				healthAuthorityID = ha.ID
+				// Extract the public key from the PEM block.
+				return hak.PublicKey()
+			}
+		}
+		return nil, fmt.Errorf("key not found: iss: %v version: %v", claims.Issuer, claims.KeyVersion)
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	if !token.Valid {
+		return nil, fmt.Errorf("invalid verificationPayload")
+	}
+	if err := claims.Valid(); err != nil {
+		return nil, err
+	}
+
+	// JWT is valid and signature is valid.
+	// This is chacked after the signature verification to prevent timing attacks.
+	if _, ok := authApp.AllowedHealthAuthorityIDs[healthAuthorityID]; !ok {
+		return nil, fmt.Errorf("app %v has not authorized health authority issuer: %v", authApp.AppPackageName, claims.Issuer)
+	}
+
+	// Verify the HMAC.
+	jwtHMAC, err := base64util.DecodeString(claims.SignedMAC)
+	if err != nil {
+		return nil, fmt.Errorf("error decoding HMAC from claims: %w", err)
+	}
+	secret, err := base64util.DecodeString(publish.HMACKey)
+	if err != nil {
+		return nil, fmt.Errorf("error decoding HMAC secret from publish request: %w", err)
+	}
+	wantHMAC, err := utils.CalculateExposureKeyHMAC(publish.Keys, secret)
+	if err != nil {
+		return nil, fmt.Errorf("calculating expected HMAC: %w", err)
+	}
+
+	if !hmac.Equal(wantHMAC, jwtHMAC) {
+		return nil, fmt.Errorf("HMAC mismatch, publish request does not match disgnosis verification certificate")
+	}
+
+	// Everything looks good. Return the transmission override vector.
+	return claims.TransmissionRisks, nil
+}
diff --git a/migrations/000026_health_authority_config.down.sql b/migrations/000026_health_authority_config.down.sql
new file mode 100644
index 0000000..e37e3fc
--- /dev/null
+++ b/migrations/000026_health_authority_config.down.sql
@@ -0,0 +1,22 @@
+-- Copyright 2020 Google LLC
+--
+-- Licensed under the Apache License, Version 2.0 (the "License");
+-- you may not use this file except in compliance with the License.
+-- You may obtain a copy of the License at
+--
+--      http://www.apache.org/licenses/LICENSE-2.0
+--
+-- Unless required by applicable law or agreed to in writing, software
+-- distributed under the License is distributed on an "AS IS" BASIS,
+-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+-- See the License for the specific language governing permissions and
+-- limitations under the License.
+
+BEGIN;
+
+DROP TABLE HealthAuthorityKey;
+DROP TABLE HealthAuthority;
+
+ALTER TABLE AuthorizedApp DROP COLUMN allowed_health_authority_ids;
+
+END;
diff --git a/migrations/000026_health_authority_config.up.sql b/migrations/000026_health_authority_config.up.sql
new file mode 100644
index 0000000..cd49b0b
--- /dev/null
+++ b/migrations/000026_health_authority_config.up.sql
@@ -0,0 +1,38 @@
+-- Copyright 2020 Google LLC
+--
+-- Licensed under the Apache License, Version 2.0 (the "License");
+-- you may not use this file except in compliance with the License.
+-- You may obtain a copy of the License at
+--
+--      http://www.apache.org/licenses/LICENSE-2.0
+--
+-- Unless required by applicable law or agreed to in writing, software
+-- distributed under the License is distributed on an "AS IS" BASIS,
+-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+-- See the License for the specific language governing permissions and
+-- limitations under the License.
+
+BEGIN;
+
+CREATE TABLE HealthAuthority(
+	id SERIAL PRIMARY KEY,
+	iss VARCHAR(200) NOT NULL,
+	aud VARCHAR(200) NOT NULL,
+  name VARCHAR(200) NOT NULL,
+  CONSTRAINT uniqueiss UNIQUE(iss)
+);
+
+CREATE TABLE HealthAuthorityKey(
+	health_authority_id INT NOT NULL REFERENCES HealthAuthority(id),
+	version varchar(100) NOT NULL,
+	from_timestamp TIMESTAMPTZ NOT NULL,
+	thru_timestamp TIMESTAMPTZ,
+	public_key VARCHAR(500),
+	PRIMARY KEY(health_authority_id, version)
+);
+
+ALTER TABLE AuthorizedApp
+  ADD COLUMN allowed_health_authority_ids int[];
+
+
+END;
diff --git a/migrations/000027_add_authoried_app_require_pha.down.sql b/migrations/000027_add_authoried_app_require_pha.down.sql
new file mode 100644
index 0000000..dd0ca7d
--- /dev/null
+++ b/migrations/000027_add_authoried_app_require_pha.down.sql
@@ -0,0 +1,20 @@
+-- Copyright 2020 Google LLC
+--
+-- Licensed under the Apache License, Version 2.0 (the "License");
+-- you may not use this file except in compliance with the License.
+-- You may obtain a copy of the License at
+--
+--      http://www.apache.org/licenses/LICENSE-2.0
+--
+-- Unless required by applicable law or agreed to in writing, software
+-- distributed under the License is distributed on an "AS IS" BASIS,
+-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+-- See the License for the specific language governing permissions and
+-- limitations under the License.
+
+BEGIN;
+
+ALTER TABLE AuthorizedApp
+  DROP COLUMN bypass_health_authority_verification;
+
+END;
diff --git a/migrations/000027_add_authoried_app_require_pha.up.sql b/migrations/000027_add_authoried_app_require_pha.up.sql
new file mode 100644
index 0000000..a355101
--- /dev/null
+++ b/migrations/000027_add_authoried_app_require_pha.up.sql
@@ -0,0 +1,20 @@
+-- Copyright 2020 Google LLC
+--
+-- Licensed under the Apache License, Version 2.0 (the "License");
+-- you may not use this file except in compliance with the License.
+-- You may obtain a copy of the License at
+--
+--      http://www.apache.org/licenses/LICENSE-2.0
+--
+-- Unless required by applicable law or agreed to in writing, software
+-- distributed under the License is distributed on an "AS IS" BASIS,
+-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+-- See the License for the specific language governing permissions and
+-- limitations under the License.
+
+BEGIN;
+
+ALTER TABLE AuthorizedApp
+  ADD COLUMN bypass_health_authority_verification bool DEFAULT false NOT NULL;
+
+END;
diff --git a/pkg/api/v1alpha1/verification_types.go b/pkg/api/v1alpha1/verification_types.go
new file mode 100644
index 0000000..579baab
--- /dev/null
+++ b/pkg/api/v1alpha1/verification_types.go
@@ -0,0 +1,74 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package v1alpha1 contains API definitions that can be used outside of this
+// codebase in their alpha form.
+// These APIs are not considered to be stable at HEAD.
+package v1alpha1
+
+import (
+	"sort"
+
+	"github.com/dgrijalva/jwt-go"
+)
+
+const (
+	ExposureKeyHMACClaim          = "tekmac"
+	HealthAuthorityDataClaim      = "phadata"
+	TransmissionRiskOverrideClaim = "trisk"
+	KeyVersionClaim               = "keyVersion"
+)
+
+// TransmissionRiskVector is an additional set of claims that can be
+// included in the verification certificatin for a diagnosis as received
+// from a trusted public health authority.
+type TransmissionRiskVector []TransmissionRiskOverride
+
+// Compile time check that TranismissionRiskVector implements the sort interface.
+var _ sort.Interface = TransmissionRiskVector{}
+
+// TransmissionRiskOverride is an indvidual transmission risk override.
+type TransmissionRiskOverride struct {
+	TranismissionRisk  int `json:"tr"`
+	SinceRollingPeriod int `json:"sinceRollingPeriod"`
+}
+
+func (a TransmissionRiskVector) Len() int {
+	return len(a)
+}
+
+// This sorts the TransmissionRiskVector vector with the largest SinceRollingPeriod
+// value first. Descending sort.
+func (a TransmissionRiskVector) Less(i, j int) bool {
+	return a[i].SinceRollingPeriod > a[j].SinceRollingPeriod
+}
+
+func (a TransmissionRiskVector) Swap(i, j int) {
+	a[i], a[j] = a[j], a[i]
+}
+
+type VerificationClaims struct {
+	PHAClaims         map[string]string      `json:"phadata"`
+	TransmissionRisks TransmissionRiskVector `json:"trisk"`
+	SignedMAC         string                 `json:"tekmac"`
+	KeyVersion        string                 `json:"keyVersion"`
+	jwt.StandardClaims
+}
+
+func NewVerificationClaims() *VerificationClaims {
+	return &VerificationClaims{
+		PHAClaims:         make(map[string]string),
+		TransmissionRisks: []TransmissionRiskOverride{},
+	}
+}
diff --git a/pkg/verification/utils.go b/pkg/verification/utils.go
new file mode 100644
index 0000000..79d7367
--- /dev/null
+++ b/pkg/verification/utils.go
@@ -0,0 +1,52 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package verification
+
+import (
+	"crypto/hmac"
+	"crypto/sha256"
+	"fmt"
+	"sort"
+	"strings"
+
+	"github.com/google/exposure-notifications-server/internal/publish/model"
+)
+
+// CalculateExposureKeyHMAC will calculate the verification protocol HMAC value.
+// Input keys are already to be base64 encoded. They will be sorted if necessary.
+func CalculateExposureKeyHMAC(keys []model.ExposureKey, secret []byte) ([]byte, error) {
+	if len(keys) == 0 {
+		return nil, fmt.Errorf("cannot calculate hmac on empty exposure keys")
+	}
+	// Sort by the key
+	sort.Slice(keys, func(i int, j int) bool {
+		return strings.Compare(keys[i].Key, keys[j].Key) <= 0
+	})
+
+	// Build the cleartext.
+	perKeyText := make([]string, 0, len(keys))
+	for _, ek := range keys {
+		perKeyText = append(perKeyText,
+			fmt.Sprintf("%s.%d.%d.%d", ek.Key, ek.IntervalNumber, ek.IntervalCount, ek.TransmissionRisk))
+	}
+
+	cleartext := strings.Join(perKeyText, ",")
+	mac := hmac.New(sha256.New, secret)
+	if _, err := mac.Write([]byte(cleartext)); err != nil {
+		return nil, fmt.Errorf("failed to write hmac: %w", err)
+	}
+
+	return mac.Sum(nil), nil
+}
diff --git a/scripts/dev b/scripts/dev
index 7d671cb..98f2cb8 100755
--- a/scripts/dev
+++ b/scripts/dev
@@ -184,6 +184,20 @@ function seed() {
   echo "Database seeded (OK)"
 }
 
+function dbclear() {
+  docker exec \
+    --interactive \
+    --env "PGPASSWORD=${DB_PASSWORD}" \
+    "${DB_NAME}" \
+    /usr/local/bin/psql \
+      --dbname "${DB_NAME}" \
+      --username "${DB_USER}" \
+      --port "5432" \
+      < "${ROOT}/testdata/clear-export.sql"
+
+  echo "Database cleared (OK)"
+}
+
 # dburl returns the full connection url.
 function dburl() {
   echo "${DB_URL}"
@@ -216,6 +230,7 @@ function help() {
   echo 1>&2 "  dbstop       stop the dev server"
   echo 1>&2 "  dbmigrate    run migrations"
   echo 1>&2 "  dbseed       seed dev data"
+  echo 1>&2 "  dbclear      clear export data and exposure keys"
   echo 1>&2 "  dburl        print url"
   echo 1>&2 "  dbshell      attach a psql session"
   echo 1>&2 "  protoc       re-compile protos"
@@ -257,6 +272,11 @@ case "${SUBCOMMAND}" in
     seed "$@"
     ;;
 
+  "dbclear" )
+    shift
+    dbclear "$@"
+    ;;
+
   "dburl" )
     shift
     dburl "$@"
diff --git a/terraform/README.md b/terraform/README.md
index 30372e3..4bae42f 100644
--- a/terraform/README.md
+++ b/terraform/README.md
@@ -69,7 +69,7 @@ For full instructions on deploying, view the [deployment docs](../docs/deploying
     $ gsutil mb -p ${PROJECT_ID} gs://${PROJECT_ID}-tf-state
     ```
 
-    Configurre Terraform to store state in the bucket:
+    Configure Terraform to store state in the bucket:
 
     ```text
     $ cat <<EOF > ./terraform/state.tf
@@ -111,3 +111,16 @@ project               = "..."
 cloudsql_tier         = "db-custom-1-3840"
 cloudsql_disk_size_gb = "16"
 ```
+
+### Changing Regions
+
+The target cloud region for each resource types are exposed as Terraform
+variables in `vars.tf`. Each region or location variable may be changed,
+however, they are not necessarily independent. The comments for each variable
+make a note of required dependencies and also link to the associated docs page
+listing the valid values.
+
+Note that not all resources used by this project are currently available in all
+regions, but bringing up infrastructure in different regions needs careful
+consideration as geographic location of resources does impact service
+performance.
diff --git a/terraform/database.tf b/terraform/database.tf
index e00d450..1404067 100644
--- a/terraform/database.tf
+++ b/terraform/database.tf
@@ -21,7 +21,7 @@ resource "random_string" "db-name" {
 
 resource "google_sql_database_instance" "db-inst" {
   project          = data.google_project.project.project_id
-  region           = var.region
+  region           = var.db_location
   database_version = "POSTGRES_11"
   name             = "en-${random_string.db-name.result}"
 
@@ -166,7 +166,7 @@ resource "null_resource" "migrate" {
       DB_USER        = google_sql_user.user.name
       COMMAND        = "up"
 
-      REGION   = var.region
+      REGION   = var.db_location
       SERVICES = "all"
       TAG      = "initial"
     }
diff --git a/terraform/key_management.tf b/terraform/key_management.tf
index 9ca4696..0932760 100644
--- a/terraform/key_management.tf
+++ b/terraform/key_management.tf
@@ -15,7 +15,7 @@
 resource "google_kms_key_ring" "export-signing" {
   project  = data.google_project.project.project_id
   name     = "export-signing"
-  location = var.region
+  location = var.kms_location
 
   depends_on = [
     google_project_service.services["cloudkms.googleapis.com"],
diff --git a/terraform/main.tf b/terraform/main.tf
index 5d3ac36..298452d 100644
--- a/terraform/main.tf
+++ b/terraform/main.tf
@@ -76,7 +76,7 @@ resource "google_service_networking_connection" "private_vpc_connection" {
 resource "google_vpc_access_connector" "connector" {
   project       = data.google_project.project.project_id
   name          = "serverless-vpc-connector"
-  region        = var.region
+  region        = var.network_location
   network       = "default"
   ip_cidr_range = "10.8.0.0/28"
 
@@ -91,7 +91,7 @@ resource "null_resource" "build" {
   provisioner "local-exec" {
     environment = {
       PROJECT_ID = data.google_project.project.project_id
-      REGION     = var.region
+      REGION     = var.cloudrun_location
       SERVICES   = "all"
       TAG        = "initial"
     }
@@ -172,6 +172,34 @@ output "region" {
   value = var.region
 }
 
+output "db_location" {
+  value = var.db_location
+}
+
+output "network_location" {
+  value = var.network_location
+}
+
+output "kms_location" {
+  value = var.kms_location
+}
+
+output "appengine_location" {
+  value = var.appengine_location
+}
+
+output "cloudscheduler_location" {
+  value = var.cloudscheduler_location
+}
+
+output "cloudrun_location" {
+  value = var.cloudrun_location
+}
+
+output "storage_location" {
+  value = var.storage_location
+}
+
 output "project" {
   value = data.google_project.project.project_id
 }
diff --git a/terraform/service_cleanup_export.tf b/terraform/service_cleanup_export.tf
index 6002848..f1c8000 100644
--- a/terraform/service_cleanup_export.tf
+++ b/terraform/service_cleanup_export.tf
@@ -55,7 +55,7 @@ resource "google_storage_bucket_iam_member" "cleanup-export-objectadmin" {
 
 resource "google_cloud_run_service" "cleanup-export" {
   name     = "cleanup-export"
-  location = var.region
+  location = var.cloudrun_location
 
   template {
     spec {
@@ -78,6 +78,14 @@ resource "google_cloud_run_service" "cleanup-export" {
             value = env.value["value"]
           }
         }
+
+        dynamic "env" {
+          for_each = lookup(var.service_environment, "cleanup_export", {})
+          content {
+            name  = env.key
+            value = env.value
+          }
+        }
       }
     }
 
@@ -123,6 +131,7 @@ resource "google_cloud_run_service_iam_member" "cleanup-export-invoker" {
 
 resource "google_cloud_scheduler_job" "cleanup-export-worker" {
   name             = "cleanup-export-worker"
+  region           = var.cloudscheduler_location
   schedule         = "0 */6 * * *"
   time_zone        = "Etc/UTC"
   attempt_deadline = "600s"
diff --git a/terraform/service_cleanup_exposure.tf b/terraform/service_cleanup_exposure.tf
index 517e4d1..bdf5a78 100644
--- a/terraform/service_cleanup_exposure.tf
+++ b/terraform/service_cleanup_exposure.tf
@@ -49,7 +49,7 @@ resource "google_secret_manager_secret_iam_member" "cleanup-exposure-db" {
 
 resource "google_cloud_run_service" "cleanup-exposure" {
   name     = "cleanup-exposure"
-  location = var.region
+  location = var.cloudrun_location
 
   template {
     spec {
@@ -72,6 +72,14 @@ resource "google_cloud_run_service" "cleanup-exposure" {
             value = env.value["value"]
           }
         }
+
+        dynamic "env" {
+          for_each = lookup(var.service_environment, "cleanup_exposure", {})
+          content {
+            name  = env.key
+            value = env.value
+          }
+        }
       }
     }
 
@@ -117,6 +125,7 @@ resource "google_cloud_run_service_iam_member" "cleanup-exposure-invoker" {
 
 resource "google_cloud_scheduler_job" "cleanup-exposure-worker" {
   name             = "cleanup-exposure-worker"
+  region           = var.cloudscheduler_location
   schedule         = "0 */4 * * *"
   time_zone        = "Etc/UTC"
   attempt_deadline = "600s"
diff --git a/terraform/service_export.tf b/terraform/service_export.tf
index 4b70dcf..3b62ab8 100644
--- a/terraform/service_export.tf
+++ b/terraform/service_export.tf
@@ -61,7 +61,7 @@ resource "google_kms_key_ring_iam_member" "export-signerverifier" {
 
 resource "google_cloud_run_service" "export" {
   name     = "export"
-  location = var.region
+  location = var.cloudrun_location
 
   template {
     spec {
@@ -85,14 +85,12 @@ resource "google_cloud_run_service" "export" {
           }
         }
 
-        env {
-          name  = "EXPORT_FILE_MAX_RECORDS"
-          value = "100"
-        }
-
-        env {
-          name  = "EXPORT_BUCKET"
-          value = google_storage_bucket.export.name
+        dynamic "env" {
+          for_each = lookup(var.service_environment, "export", {})
+          content {
+            name  = env.key
+            value = env.value
+          }
         }
       }
 
@@ -141,6 +139,7 @@ resource "google_cloud_run_service_iam_member" "export-invoker" {
 
 resource "google_cloud_scheduler_job" "export-worker" {
   name             = "export-worker"
+  region           = var.cloudscheduler_location
   schedule         = "* * * * *"
   time_zone        = "Etc/UTC"
   attempt_deadline = "600s"
@@ -167,6 +166,7 @@ resource "google_cloud_scheduler_job" "export-worker" {
 
 resource "google_cloud_scheduler_job" "export-create-batches" {
   name             = "export-create-batches"
+  region           = var.cloudscheduler_location
   schedule         = "*/5 * * * *"
   time_zone        = "Etc/UTC"
   attempt_deadline = "600s"
diff --git a/terraform/service_exposure.tf b/terraform/service_exposure.tf
index d7dec02..0ade75e 100644
--- a/terraform/service_exposure.tf
+++ b/terraform/service_exposure.tf
@@ -49,7 +49,7 @@ resource "google_secret_manager_secret_iam_member" "exposure-db" {
 
 resource "google_cloud_run_service" "exposure" {
   name     = "exposure"
-  location = var.region
+  location = var.cloudrun_location
 
   template {
     spec {
@@ -72,6 +72,14 @@ resource "google_cloud_run_service" "exposure" {
             value = env.value["value"]
           }
         }
+
+        dynamic "env" {
+          for_each = lookup(var.service_environment, "exposure", {})
+          content {
+            name  = env.key
+            value = env.value
+          }
+        }
       }
     }
 
diff --git a/terraform/service_federationin.tf b/terraform/service_federationin.tf
index a35c998..dbbb201 100644
--- a/terraform/service_federationin.tf
+++ b/terraform/service_federationin.tf
@@ -49,7 +49,7 @@ resource "google_secret_manager_secret_iam_member" "federationin" {
 
 resource "google_cloud_run_service" "federationin" {
   name     = "federationin"
-  location = var.region
+  location = var.cloudrun_location
 
   template {
     spec {
@@ -72,6 +72,14 @@ resource "google_cloud_run_service" "federationin" {
             value = env.value["value"]
           }
         }
+
+        dynamic "env" {
+          for_each = lookup(var.service_environment, "federationin", {})
+          content {
+            name  = env.key
+            value = env.value
+          }
+        }
       }
     }
 
diff --git a/terraform/service_federationout.tf b/terraform/service_federationout.tf
index 53fce8d..088e5ce 100644
--- a/terraform/service_federationout.tf
+++ b/terraform/service_federationout.tf
@@ -49,7 +49,7 @@ resource "google_secret_manager_secret_iam_member" "federationout-db" {
 
 resource "google_cloud_run_service" "federationout" {
   name     = "federationout"
-  location = var.region
+  location = var.cloudrun_location
 
   template {
     spec {
@@ -72,6 +72,14 @@ resource "google_cloud_run_service" "federationout" {
             value = env.value["value"]
           }
         }
+
+        dynamic "env" {
+          for_each = lookup(var.service_environment, "federationout", {})
+          content {
+            name  = env.key
+            value = env.value
+          }
+        }
       }
     }
 
diff --git a/terraform/service_gcr_cleaner.tf b/terraform/service_gcr_cleaner.tf
index 4a53f3c..3cd05bf 100644
--- a/terraform/service_gcr_cleaner.tf
+++ b/terraform/service_gcr_cleaner.tf
@@ -44,7 +44,7 @@ resource "google_storage_bucket_iam_member" "gcr-cleaner-objectadmin" {
 
 resource "google_cloud_run_service" "gcr-cleaner" {
   name     = "gcr-cleaner"
-  location = var.region
+  location = var.cloudrun_location
 
   template {
     spec {
diff --git a/terraform/service_generate.tf b/terraform/service_generate.tf
index ac6c52c..41dcb8f 100644
--- a/terraform/service_generate.tf
+++ b/terraform/service_generate.tf
@@ -49,7 +49,7 @@ resource "google_secret_manager_secret_iam_member" "generate-db" {
 
 resource "google_cloud_run_service" "generate" {
   name     = "generate"
-  location = var.region
+  location = var.cloudrun_location
 
   template {
     spec {
@@ -72,6 +72,14 @@ resource "google_cloud_run_service" "generate" {
             value = env.value["value"]
           }
         }
+
+        dynamic "env" {
+          for_each = lookup(var.service_environment, "generate", {})
+          content {
+            name  = env.key
+            value = env.value
+          }
+        }
       }
     }
 
diff --git a/terraform/storage.tf b/terraform/storage.tf
index acc7372..78df25f 100644
--- a/terraform/storage.tf
+++ b/terraform/storage.tf
@@ -21,6 +21,7 @@ resource "random_string" "bucket-name" {
 
 resource "google_storage_bucket" "export" {
   project            = data.google_project.project.project_id
+  location           = var.storage_location
   name               = "exposure-notification-export-${random_string.bucket-name.result}"
   bucket_policy_only = true
 }
diff --git a/terraform/vars.tf b/terraform/vars.tf
index 3518023..38ae6fd 100644
--- a/terraform/vars.tf
+++ b/terraform/vars.tf
@@ -12,28 +12,71 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+# The default region for resources in the project, individual resources should
+# have more specific variables defined to specify their region/location which
+# increases the flexibility of deployments
 variable "region" {
   type    = string
   default = "us-central1"
 }
 
+# The region in which to put the SQL DB: it is currently configured to use
+# PostgreSQL.
+# https://cloud.google.com/sql/docs/postgres/locations
+variable "db_location" {
+  type    = string
+  default = "us-central1"
+}
+
+# The region for the networking components.
+# https://cloud.google.com/compute/docs/regions-zones
+variable "network_location" {
+  type    = string
+  default = "us-central1"
+}
+
+# The region for the key management service.
+# https://cloud.google.com/kms/docs/locations
+variable "kms_location" {
+  type    = string
+  default = "us-central1"
+}
+
+# The location for the app engine; this implicitly defines the region for
+# scheduler jobs as specified by the cloudscheduler_location variable but the
+# values are sometimes different (as in the default values) so they are kept as
+# separate variables.
+# https://cloud.google.com/appengine/docs/locations
 variable "appengine_location" {
   type    = string
   default = "us-central"
 }
 
-variable "project" {
-  type = string
+# The cloudscheduler_location MUST use the same region as appengine_location but
+# it must include the region number even if this is omitted from the
+# appengine_location (as in the default values).
+variable "cloudscheduler_location" {
+  type    = string
+  default = "us-central1"
 }
 
-variable "repo_owner" {
+
+# The region in which cloudrun jobs are executed.
+# https://cloud.google.com/run/docs/locations
+variable "cloudrun_location" {
   type    = string
-  default = "google"
+  default = "us-central1"
 }
 
-variable "repo_name" {
+# The location holding the storage bucket for exported files.
+# https://cloud.google.com/storage/docs/locations
+variable "storage_location" {
   type    = string
-  default = "exposure-notifications-server"
+  default = "US"
+}
+
+variable "project" {
+  type = string
 }
 
 variable "cloudsql_tier" {
@@ -64,6 +107,13 @@ variable "registry_cleanup_cron_schedule" {
   description = "Schedule to execute cleanup of old Container Registry images."
 }
 
+variable "service_environment" {
+  type    = map(map(string))
+  default = {}
+
+  description = "Per-service environment overrides."
+}
+
 terraform {
   required_providers {
     google      = "~> 3.20"
diff --git a/tools/admin-console/main.go b/tools/admin-console/main.go
index 34fbb72..39fbbdd 100644
--- a/tools/admin-console/main.go
+++ b/tools/admin-console/main.go
@@ -65,5 +65,7 @@ func main() {
 	router.POST("/siginfo/:id", saveSigInfoController.Execute)
 
 	log.Printf("listening on http://localhost:" + config.Port)
-	router.Run()
+	if err := router.Run(); err != nil {
+		log.Fatal(err)
+	}
 }
diff --git a/tools/export-analyzer/main.go b/tools/export-analyzer/main.go
index 7713664..10afae1 100644
--- a/tools/export-analyzer/main.go
+++ b/tools/export-analyzer/main.go
@@ -46,5 +46,8 @@ func main() {
 	}
 
 	prettyJSON, err := json.MarshalIndent(keyExport, "", "  ")
+	if err != nil {
+		log.Fatalf("error pretty printing export: %v", err)
+	}
 	log.Printf("%v", string(prettyJSON))
 }
diff --git a/tools/export-generate/main.go b/tools/export-generate/main.go
index e3157f6..e10a6a3 100644
--- a/tools/export-generate/main.go
+++ b/tools/export-generate/main.go
@@ -61,11 +61,11 @@ func main() {
 		log.Fatal("--signing-key is required.")
 	}
 
-	// set endTime default to now, startTime default to (now - 24h)
+	// set endTime default to now, startTime default to (now - 24h).
 	endTime := time.Now()
 	startTime := endTime.Add(-time.Hour * 24)
 
-	// command-line flags can override startTime and endTime
+	// command-line flags can override startTime and endTime.
 	if *startTimestamp != "" {
 		var err error
 		startTime, err = time.Parse(time.RFC3339, *startTimestamp)
@@ -82,14 +82,14 @@ func main() {
 		}
 	}
 
-	// parse signing key
+	// parse signing key.
 	var privateKey *ecdsa.PrivateKey
 	privateKey, err := getSigningKey(*signingKey)
 	if err != nil {
 		log.Fatalf("unable to generate signing key: %v", err)
 	}
 
-	// generate fake keys
+	// generate fake keys.
 	var actualNumKeys int
 	var exposureKeys []publishmodel.Exposure
 	if *tekFile != "" {
@@ -99,7 +99,7 @@ func main() {
 			log.Fatalf("unable to read file: %v", err)
 		}
 		data := publishmodel.ExposureKeys{}
-		err = json.Unmarshal([]byte(file), &data)
+		err = json.Unmarshal(file, &data)
 		if err != nil {
 			log.Fatalf("unable to parse json: %v", err)
 		}
@@ -137,7 +137,7 @@ func main() {
 		}
 	}
 
-	// split up into batches
+	// split up into batches.
 	eb := &model.ExportBatch{
 		FilenameRoot:   *filenameRoot,
 		StartTimestamp: startTime,
@@ -167,11 +167,11 @@ func writeFile(eb *model.ExportBatch, currentBatch []*publishmodel.Exposure, b,
 		SigningKeyID:      *keyID,
 		SigningKeyVersion: *keyVersion,
 	}
-	signer := export.ExportSigners{
+	signer := &export.Signer{
 		SignatureInfo: signatureInfo,
 		Signer:        privateKey,
 	}
-	data, err := export.MarshalExportFile(eb, currentBatch, b, numBatches, []export.ExportSigners{signer})
+	data, err := export.MarshalExportFile(eb, currentBatch, b, numBatches, []*export.Signer{signer})
 	if err != nil {
 		log.Fatalf("error marshalling export file: %v", err)
 	}
@@ -188,19 +188,19 @@ func getSigningKey(fileName string) (*ecdsa.PrivateKey, error) {
 	return ParseECPrivateKeyFromPEM(keyBytes)
 }
 
-// Parse PEM encoded Elliptic Curve Private Key Structure
+// Parse PEM encoded Elliptic Curve Private Key Structure.
 func ParseECPrivateKeyFromPEM(key []byte) (*ecdsa.PrivateKey, error) {
 	ErrNotECPrivateKey := errors.New("key is not a valid ECDSA private key")
 	ErrKeyMustBePEMEncoded := errors.New("invalid Key: Key must be PEM encoded PKCS1 or PKCS8 private key")
 	var err error
 
-	// Parse PEM block
+	// Parse PEM block.
 	var block *pem.Block
 	if block, _ = pem.Decode(key); block == nil {
 		return nil, ErrKeyMustBePEMEncoded
 	}
 
-	// Parse the key
+	// Parse the key.
 	var parsedKey interface{}
 	if parsedKey, err = x509.ParseECPrivateKey(block.Bytes); err != nil {
 		return nil, err
diff --git a/tools/unwrap-signature/main.go b/tools/unwrap-signature/main.go
index 0d9f9c6..f0dea6b 100644
--- a/tools/unwrap-signature/main.go
+++ b/tools/unwrap-signature/main.go
@@ -46,7 +46,9 @@ func main() {
 	}
 
 	teksl := &export.TEKSignatureList{}
-	proto.Unmarshal(inData, teksl)
+	if err := proto.Unmarshal(inData, teksl); err != nil {
+		log.Fatalf("failed to unmarshal proto: %v", err)
+	}
 
 	log.Printf("Data: \n%v", teksl)
 	sig := teksl.Signatures[0].Signature


Test patch:
diff --git a/internal/android/safetynet_test.go b/internal/android/safetynet_test.go
index bc8e3ff..fdbccf2 100644
--- a/internal/android/safetynet_test.go
+++ b/internal/android/safetynet_test.go
@@ -202,8 +202,8 @@ func TestVerifyOptsFor(t *testing.T) {
 				AppPackageName:           "foo",
 				SafetyNetBasicIntegrity:  true,
 				SafetyNetCTSProfileMatch: true,
-				SafetyNetPastTime:        time.Duration(15 * time.Minute),
-				SafetyNetFutureTime:      time.Duration(1 * time.Second),
+				SafetyNetPastTime:        15 * time.Minute,
+				SafetyNetFutureTime:      1 * time.Second,
 			},
 			opts: &VerifyOpts{
 				AppPkgName:      "foo",
diff --git a/internal/authorizedapp/database/authorized_app_test.go b/internal/authorizedapp/database/authorized_app_test.go
index da529bd..55918f6 100644
--- a/internal/authorizedapp/database/authorized_app_test.go
+++ b/internal/authorizedapp/database/authorized_app_test.go
@@ -54,11 +54,12 @@ func TestAuthorizedAppLifecycle(t *testing.T) {
 	}
 
 	source := &model.AuthorizedApp{
-		AppPackageName:      "myapp",
-		Platform:            "both",
-		AllowedRegions:      map[string]struct{}{"US": {}},
-		SafetyNetDisabled:   true,
-		DeviceCheckDisabled: true,
+		AppPackageName:            "myapp",
+		Platform:                  "both",
+		AllowedRegions:            map[string]struct{}{"US": {}},
+		AllowedHealthAuthorityIDs: map[int64]struct{}{1: {}, 2: {}},
+		SafetyNetDisabled:         true,
+		DeviceCheckDisabled:       true,
 	}
 
 	if err := aadb.InsertAuthorizedApp(ctx, source); err != nil {
@@ -134,57 +135,61 @@ func TestGetAuthorizedApp(t *testing.T) {
 		{
 			name: "bare",
 			sql: `
-				INSERT INTO AuthorizedApp (app_package_name, platform, allowed_regions)
-				VALUES ($1, $2, $3)
+				INSERT INTO AuthorizedApp (app_package_name, platform, allowed_regions, allowed_health_authority_ids)
+				VALUES ($1, $2, $3, $4)
 			`,
-			args: []interface{}{"myapp", "android", []string{"US"}},
+			args: []interface{}{"myapp", "android", []string{"US"}, []int64{1}},
 			exp: &model.AuthorizedApp{
-				AppPackageName:           "myapp",
-				Platform:                 "android",
-				AllowedRegions:           map[string]struct{}{"US": {}},
-				SafetyNetBasicIntegrity:  true,
-				SafetyNetCTSProfileMatch: true,
+				AppPackageName:            "myapp",
+				Platform:                  "android",
+				AllowedRegions:            map[string]struct{}{"US": {}},
+				AllowedHealthAuthorityIDs: map[int64]struct{}{1: {}},
+				SafetyNetBasicIntegrity:   true,
+				SafetyNetCTSProfileMatch:  true,
 			},
 		},
 		{
 			name: "all_regions",
 			sql: `
-				INSERT INTO AuthorizedApp (app_package_name, platform, allowed_regions)
-				VALUES ($1, $2, $3)
+				INSERT INTO AuthorizedApp (app_package_name, platform, allowed_regions, allowed_health_authority_ids)
+				VALUES ($1, $2, $3, $4)
 			`,
-			args: []interface{}{"myapp", "android", []string{}},
+			args: []interface{}{"myapp", "android", []string{}, []int64{1}},
 			exp: &model.AuthorizedApp{
-				AppPackageName:           "myapp",
-				Platform:                 "android",
-				AllowedRegions:           map[string]struct{}{},
-				SafetyNetBasicIntegrity:  true,
-				SafetyNetCTSProfileMatch: true,
+				AppPackageName:            "myapp",
+				Platform:                  "android",
+				AllowedRegions:            map[string]struct{}{},
+				AllowedHealthAuthorityIDs: map[int64]struct{}{1: {}},
+				SafetyNetBasicIntegrity:   true,
+				SafetyNetCTSProfileMatch:  true,
 			},
 		},
 		{
 			name: "safetynet_fileds",
 			sql: `
 				INSERT INTO AuthorizedApp (
-					app_package_name, platform, allowed_regions,
+					app_package_name, platform, allowed_regions, bypass_health_authority_verification,
 					safetynet_disabled, safetynet_apk_digest, safetynet_cts_profile_match, safetynet_basic_integrity
 				)
 				VALUES (
-					$1, $2, $3,
-					$4, $5, $6, $7
+					$1, $2, $3, $4,
+					$5, $6, $7, $8
 				)
 			`,
 			args: []interface{}{
-				"myapp", "android", []string{},
+				"myapp", "android", []string{}, true,
 				false, []string{"092fcfb", "252f10c"}, false, false,
 			},
 			exp: &model.AuthorizedApp{
-				AppPackageName:           "myapp",
-				Platform:                 "android",
-				AllowedRegions:           map[string]struct{}{},
-				SafetyNetDisabled:        false,
-				SafetyNetApkDigestSHA256: []string{"092fcfb", "252f10c"},
-				SafetyNetBasicIntegrity:  false,
-				SafetyNetCTSProfileMatch: false,
+				AppPackageName:                    "myapp",
+				Platform:                          "android",
+				AllowedRegions:                    map[string]struct{}{},
+				AllowedHealthAuthorityIDs:         map[int64]struct{}{},
+				BypassHealthAuthorityVerification: true,
+				SafetyNetDisabled:                 false,
+				SafetyNetApkDigestSHA256:          []string{"092fcfb", "252f10c"},
+				SafetyNetBasicIntegrity:           false,
+				SafetyNetCTSProfileMatch:          false,
 			},
 		},
 
@@ -198,12 +203,13 @@ func TestGetAuthorizedApp(t *testing.T) {
 			`,
 			args: []interface{}{"myapp", "android", []string{"US"}, 1800},
 			exp: &model.AuthorizedApp{
-				AppPackageName:           "myapp",
-				Platform:                 "android",
-				AllowedRegions:           map[string]struct{}{"US": {}},
-				SafetyNetBasicIntegrity:  true,
-				SafetyNetCTSProfileMatch: true,
-				SafetyNetPastTime:        30 * time.Minute,
+				AppPackageName:            "myapp",
+				Platform:                  "android",
+				AllowedRegions:            map[string]struct{}{"US": {}},
+				AllowedHealthAuthorityIDs: map[int64]struct{}{},
+				SafetyNetBasicIntegrity:   true,
+				SafetyNetCTSProfileMatch:  true,
+				SafetyNetPastTime:         30 * time.Minute,
 			},
 		},
 		{
@@ -216,12 +222,13 @@ func TestGetAuthorizedApp(t *testing.T) {
 			`,
 			args: []interface{}{"myapp", "android", []string{"US"}, 1800},
 			exp: &model.AuthorizedApp{
-				AppPackageName:           "myapp",
-				Platform:                 "android",
-				AllowedRegions:           map[string]struct{}{"US": {}},
-				SafetyNetBasicIntegrity:  true,
-				SafetyNetCTSProfileMatch: true,
-				SafetyNetFutureTime:      30 * time.Minute,
+				AppPackageName:            "myapp",
+				Platform:                  "android",
+				AllowedRegions:            map[string]struct{}{"US": {}},
+				AllowedHealthAuthorityIDs: map[int64]struct{}{},
+				SafetyNetBasicIntegrity:   true,
+				SafetyNetCTSProfileMatch:  true,
+				SafetyNetFutureTime:       30 * time.Minute,
 			},
 		},
 		{
@@ -243,6 +250,7 @@ func TestGetAuthorizedApp(t *testing.T) {
 				AppPackageName:              "myapp",
 				Platform:                    "ios",
 				AllowedRegions:              map[string]struct{}{"US": {}},
+				AllowedHealthAuthorityIDs:   map[int64]struct{}{},
 				SafetyNetCTSProfileMatch:    true,
 				SafetyNetBasicIntegrity:     true,
 				DeviceCheckDisabled:         false,
diff --git a/internal/export/batcher_test.go b/internal/export/batcher_test.go
index e86288e..9375751 100644
--- a/internal/export/batcher_test.go
+++ b/internal/export/batcher_test.go
@@ -128,7 +128,6 @@ func TestMakeBatchRanges(t *testing.T) {
 					t.Errorf("unexpected range difference between start and end for index %d, got %v, want %v", i, got[i].end.Sub(got[i].start), tc.period)
 				}
 			}
-
 		})
 	}
 }
diff --git a/internal/export/database/export_test.go b/internal/export/database/export_test.go
index 6c09a39..383e90e 100644
--- a/internal/export/database/export_test.go
+++ b/internal/export/database/export_test.go
@@ -100,7 +100,9 @@ func TestLookupSignatureInfos(t *testing.T) {
 		},
 	}
 	for _, si := range want {
-		New(testDB).AddSignatureInfo(ctx, si)
+		if err := New(testDB).AddSignatureInfo(ctx, si); err != nil {
+			t.Fatalf("failed to add signature info %v: %v", si, err)
+		}
 	}
 
 	ids := []int64{want[0].ID, want[1].ID, want[2].ID}
@@ -162,6 +164,9 @@ func TestAddGetUpdateExportConfig(t *testing.T) {
 	}
 
 	got, err = exportDB.GetExportConfig(ctx, want.ConfigID)
+	if err != nil {
+		t.Fatal(err)
+	}
 	if diff := cmp.Diff(want, got); diff != "" {
 		t.Errorf("mismatch (-want, +got):\n%s", diff)
 	}
diff --git a/internal/export/exportfile_test.go b/internal/export/exportfile_test.go
index c5ca044..0d8a7e1 100644
--- a/internal/export/exportfile_test.go
+++ b/internal/export/exportfile_test.go
@@ -20,12 +20,12 @@ import (
 	"testing"
 	"time"
 
-	"github.com/golang/protobuf/proto"
 	"github.com/google/exposure-notifications-server/internal/export/model"
 	"github.com/google/exposure-notifications-server/internal/pb/export"
 	publishmodel "github.com/google/exposure-notifications-server/internal/publish/model"
 	"github.com/google/go-cmp/cmp"
 	"github.com/google/go-cmp/cmp/cmpopts"
+	"google.golang.org/protobuf/proto"
 )
 
 func TestMarshalUnmarshalExportFile(t *testing.T) {
@@ -75,9 +75,9 @@ func TestMarshalUnmarshalExportFile(t *testing.T) {
 
 	signer := &customTestSigner{}
 
-	blob, err := MarshalExportFile(batch, exposures, 1, 1, []ExportSigners{
-		{SignatureInfo: signatureInfo,
-			Signer: signer}})
+	blob, err := MarshalExportFile(batch, exposures, 1, 1, []*Signer{
+		{SignatureInfo: signatureInfo, Signer: signer},
+	})
 	if err != nil {
 		t.Fatalf("Can't marshal export file, %v", err)
 	}
diff --git a/internal/export/model/export_model_test.go b/internal/export/model/export_model_test.go
index cb8a72e..bd04689 100644
--- a/internal/export/model/export_model_test.go
+++ b/internal/export/model/export_model_test.go
@@ -21,7 +21,7 @@ import (
 	"github.com/google/go-cmp/cmp"
 )
 
-func TextExportRegions(t *testing.T) {
+func TestExportRegions(t *testing.T) {
 	cases := []struct {
 		name         string
 		region       string
diff --git a/internal/export/server_test.go b/internal/export/server_test.go
index 58e7a9d..63c74ec 100644
--- a/internal/export/server_test.go
+++ b/internal/export/server_test.go
@@ -28,7 +28,7 @@ import (
 // TestNewServer tests NewServer().
 func TestNewServer(t *testing.T) {
 	emptyStorage := &storage.GoogleCloudStorage{}
-	emptyKMS := &signing.GCPKMS{}
+	emptyKMS := &signing.GoogleCloudKMS{}
 	emptyDB := &database.DB{}
 	ctx := context.Background()
 
diff --git a/internal/federationin/database/federationin_test.go b/internal/federationin/database/federationin_test.go
index b574534..af58470 100644
--- a/internal/federationin/database/federationin_test.go
+++ b/internal/federationin/database/federationin_test.go
@@ -21,7 +21,6 @@ import (
 	"time"
 
 	"github.com/google/exposure-notifications-server/internal/database"
-	coredb "github.com/google/exposure-notifications-server/internal/database"
 	"github.com/google/exposure-notifications-server/internal/federationin/model"
 
 	"github.com/google/go-cmp/cmp"
@@ -44,7 +43,7 @@ func TestFederationIn(t *testing.T) {
 		LastTimestamp:  ts,
 	}
 	// GetFederationQuery should fail if not found.
-	if _, err := New(testDB).GetFederationInQuery(ctx, want.QueryID); !errors.Is(err, coredb.ErrNotFound) {
+	if _, err := New(testDB).GetFederationInQuery(ctx, want.QueryID); !errors.Is(err, database.ErrNotFound) {
 		t.Errorf("got %v, want ErrNotFound", err)
 	}
 
@@ -74,7 +73,7 @@ func TestFederationIn(t *testing.T) {
 	}
 
 	// GetFederationSync should fail if not found.
-	if _, err := New(testDB).GetFederationInSync(ctx, 1); !errors.Is(err, coredb.ErrNotFound) {
+	if _, err := New(testDB).GetFederationInSync(ctx, 1); !errors.Is(err, database.ErrNotFound) {
 		t.Errorf("got %v, want ErrNotFound", err)
 	}
 
diff --git a/internal/federationout/database/federationout_test.go b/internal/federationout/database/federationout_test.go
index 9024d69..6c16817 100644
--- a/internal/federationout/database/federationout_test.go
+++ b/internal/federationout/database/federationout_test.go
@@ -20,7 +20,6 @@ import (
 	"testing"
 
 	"github.com/google/exposure-notifications-server/internal/database"
-	coredb "github.com/google/exposure-notifications-server/internal/database"
 	"github.com/google/exposure-notifications-server/internal/federationin/model"
 
 	"github.com/google/go-cmp/cmp"
@@ -43,7 +42,7 @@ func TestFederationOutAuthorization(t *testing.T) {
 	}
 
 	// GetFederationOutAuthorization should fail if not found.
-	if _, err := New(testDB).GetFederationOutAuthorization(ctx, want.Issuer, want.Subject); !errors.Is(err, coredb.ErrNotFound) {
+	if _, err := New(testDB).GetFederationOutAuthorization(ctx, want.Issuer, want.Subject); !errors.Is(err, database.ErrNotFound) {
 		t.Errorf("got %v, want ErrNotFound", err)
 	}
 	// Add a query, then get it.
diff --git a/internal/publish/database/exposure_test.go b/internal/publish/database/exposure_test.go
index b2f5335..36bb459 100644
--- a/internal/publish/database/exposure_test.go
+++ b/internal/publish/database/exposure_test.go
@@ -141,7 +141,6 @@ func TestExposures(t *testing.T) {
 	if diff := cmp.Diff(exposures[2:], got); diff != "" {
 		t.Errorf("DeleteExposures: mismatch (-want, +got):\n%s", diff)
 	}
-
 }
 
 func listExposures(ctx context.Context, db *PublishDB, c IterateExposuresCriteria) (_ []*model.Exposure, err error) {
diff --git a/internal/secrets/cacher_test.go b/internal/secrets/cacher_test.go
index 2b25062..b9c7080 100644
--- a/internal/secrets/cacher_test.go
+++ b/internal/secrets/cacher_test.go
@@ -34,10 +34,7 @@ func TestCacher_GetSecretValue(t *testing.T) {
 	ctx := context.Background()
 
 	sm := &testSecretManager{value: "first"}
-	cached, err := WrapCacher(ctx, sm, 250*time.Millisecond)
-	if err != nil {
-		t.Fatal(err)
-	}
+	cached := WrapCacher(ctx, sm, 250*time.Millisecond)
 
 	// Read the value once, which should cache it.
 	if _, err := cached.GetSecretValue(ctx, "secret"); err != nil {
diff --git a/internal/setup/setup_test.go b/internal/setup/setup_test.go
new file mode 100644
index 0000000..6319ad6
--- /dev/null
+++ b/internal/setup/setup_test.go
@@ -0,0 +1,201 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package setup_test
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	"github.com/google/exposure-notifications-server/internal/authorizedapp"
+	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/secrets"
+	"github.com/google/exposure-notifications-server/internal/setup"
+	"github.com/google/exposure-notifications-server/internal/signing"
+	"github.com/google/exposure-notifications-server/internal/storage"
+)
+
+type testDatabaseConfig struct {
+	database *database.Config
+}
+
+func (t *testDatabaseConfig) DatabaseConfig() *database.Config {
+	return t.database
+}
+
+type testSecretManagerConfig struct {
+	database *database.Config
+}
+
+func (t *testSecretManagerConfig) DatabaseConfig() *database.Config {
+	return t.database
+}
+
+func (t *testSecretManagerConfig) SecretManagerConfig() *secrets.Config {
+	return &secrets.Config{
+		SecretManagerType: secrets.SecretManagerType("GOOGLE_SECRET_MANAGER"),
+		SecretCacheTTL:    10 * time.Minute,
+	}
+}
+
+type testKeyManagerConfig struct {
+	database *database.Config
+}
+
+func (t *testKeyManagerConfig) DatabaseConfig() *database.Config {
+	return t.database
+}
+
+func (t *testKeyManagerConfig) KeyManagerConfig() *signing.Config {
+	return &signing.Config{
+		KeyManagerType: signing.KeyManagerType("GOOGLE_CLOUD_KMS"),
+	}
+}
+
+type testBlobstoreConfig struct {
+	database *database.Config
+}
+
+func (t *testBlobstoreConfig) DatabaseConfig() *database.Config {
+	return t.database
+}
+
+func (t *testBlobstoreConfig) BlobstoreConfig() *storage.Config {
+	return &storage.Config{
+		BlobstoreType: storage.BlobstoreType("GOOGLE_CLOUD_STORAGE"),
+	}
+}
+
+type testAuthorizedAppConfig struct {
+	database *database.Config
+}
+
+func (t *testAuthorizedAppConfig) DatabaseConfig() *database.Config {
+	return t.database
+}
+
+func (t *testAuthorizedAppConfig) AuthorizedAppConfig() *authorizedapp.Config {
+	return &authorizedapp.Config{}
+}
+
+func TestSetup(t *testing.T) {
+	t.Parallel()
+
+	t.Run("default", func(t *testing.T) {
+		t.Parallel()
+
+		ctx := context.Background()
+		_, dbconfig := database.NewTestDatabaseWithConfig(t)
+		config := &testDatabaseConfig{database: dbconfig}
+		env, done, err := setup.Setup(ctx, config)
+		if err != nil {
+			t.Fatal(err)
+		}
+		defer done()
+
+		db := env.Database()
+		if db == nil {
+			t.Errorf("expected db to exist")
+		}
+	})
+
+	t.Run("secret_manager", func(t *testing.T) {
+		t.Parallel()
+
+		ctx := context.Background()
+		_, dbconfig := database.NewTestDatabaseWithConfig(t)
+		config := &testSecretManagerConfig{database: dbconfig}
+		env, done, err := setup.Setup(ctx, config)
+		if err != nil {
+			t.Fatal(err)
+		}
+		defer done()
+
+		sm := env.SecretManager()
+		if sm == nil {
+			t.Errorf("expected secret manager to exist")
+		}
+
+		if _, ok := sm.(*secrets.Cacher); !ok {
+			t.Errorf("expected %T to be Cacher", sm)
+		}
+	})
+
+	t.Run("key_manager", func(t *testing.T) {
+		t.Parallel()
+
+		ctx := context.Background()
+		_, dbconfig := database.NewTestDatabaseWithConfig(t)
+		config := &testKeyManagerConfig{database: dbconfig}
+		env, done, err := setup.Setup(ctx, config)
+		if err != nil {
+			t.Fatal(err)
+		}
+		defer done()
+
+		km := env.KeyManager()
+		if km == nil {
+			t.Errorf("expected key manager to exist")
+		}
+
+		if _, ok := km.(*signing.GoogleCloudKMS); !ok {
+			t.Errorf("expected %T to be GoogleCloudKMS", km)
+		}
+	})
+
+	t.Run("blobstore", func(t *testing.T) {
+		t.Parallel()
+
+		ctx := context.Background()
+		_, dbconfig := database.NewTestDatabaseWithConfig(t)
+		config := &testBlobstoreConfig{database: dbconfig}
+		env, done, err := setup.Setup(ctx, config)
+		if err != nil {
+			t.Fatal(err)
+		}
+		defer done()
+
+		bs := env.Blobstore()
+		if bs == nil {
+			t.Errorf("expected blobstore to exist")
+		}
+
+		if _, ok := bs.(*storage.GoogleCloudStorage); !ok {
+			t.Errorf("expected %T to be GoogleCloudStorage", bs)
+		}
+	})
+
+	t.Run("authorizedapp", func(t *testing.T) {
+		t.Parallel()
+
+		ctx := context.Background()
+		_, dbconfig := database.NewTestDatabaseWithConfig(t)
+		config := &testAuthorizedAppConfig{database: dbconfig}
+		env, done, err := setup.Setup(ctx, config)
+		if err != nil {
+			t.Fatal(err)
+		}
+		defer done()
+
+		ap := env.AuthorizedAppProvider()
+		if ap == nil {
+			t.Errorf("expected appprovider to exist")
+		}
+
+		if _, ok := ap.(*authorizedapp.DatabaseProvider); !ok {
+			t.Errorf("expected %T to be DatabaseProvider", ap)
+		}
+	})
+}
diff --git a/internal/storage/filesystem_storage_test.go b/internal/storage/filesystem_storage_test.go
index c900f4e..0a9c129 100644
--- a/internal/storage/filesystem_storage_test.go
+++ b/internal/storage/filesystem_storage_test.go
@@ -63,7 +63,7 @@ func TestFilesystemStorage_CreateObject(t *testing.T) {
 				t.Fatal(err)
 			}
 
-			err = storage.CreateObject(ctx, tc.folder, tc.filepath, tc.contents)
+			err = storage.CreateObject(ctx, tc.folder, tc.filepath, tc.contents, false)
 			if (err != nil) != tc.err {
 				t.Fatal(err)
 			}
diff --git a/internal/verification/database/health_authority_db_test.go b/internal/verification/database/health_authority_db_test.go
new file mode 100644
index 0000000..66e82fe
--- /dev/null
+++ b/internal/verification/database/health_authority_db_test.go
@@ -0,0 +1,101 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package database
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/verification/model"
+
+	"github.com/google/go-cmp/cmp"
+)
+
+const (
+	validPEM = `-----BEGIN PUBLIC KEY-----
+MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEA+k9YktDK3UpOhBIy+O17biuwd/g
+IBSEEHOdgpAynz0yrHpkWL6vxjNHxRdWcImZxPgL0NVHMdY4TlsL7qaxBQ==
+-----END PUBLIC KEY-----`
+)
+
+func TestAddRetrieveHealthAuthority(t *testing.T) {
+	t.Parallel()
+
+	testDB := database.NewTestDatabase(t)
+	ctx := context.Background()
+
+	want := &model.HealthAuthority{
+		Issuer:   "doh.mystate.gov",
+		Audience: "ens.usacovid.org",
+		Name:     "My State Department of Healthiness",
+	}
+
+	haDB := New(testDB)
+	if err := haDB.AddHealthAuthority(ctx, want); err != nil {
+		t.Fatal(err)
+	}
+
+	got, err := haDB.GetHealthAuthority(ctx, want.Issuer)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if diff := cmp.Diff(want, got); diff != "" {
+		t.Fatalf("mismatch (-want, +got):\n%s", diff)
+	}
+}
+
+func TestAddRetrieveHealthAuthorityKeys(t *testing.T) {
+	t.Parallel()
+
+	testDB := database.NewTestDatabase(t)
+	ctx := context.Background()
+
+	want := &model.HealthAuthority{
+		Issuer:   "doh.mystate.gov",
+		Audience: "ens.usacovid.org",
+		Name:     "My State Department of Healthiness",
+	}
+
+	haDB := New(testDB)
+	if err := haDB.AddHealthAuthority(ctx, want); err != nil {
+		t.Fatal(err)
+	}
+
+	wantKeys := []*model.HealthAuthorityKey{
+		{
+			Version:      "v1",
+			From:         time.Now().Truncate(time.Second),
+			PublicKeyPEM: validPEM,
+		},
+	}
+
+	if err := haDB.AddHealthAuthorityKey(ctx, want, wantKeys[0]); err != nil {
+		t.Fatal(err)
+	}
+	want.Keys = wantKeys
+
+	// Reading back the HA will also read back the keys.
+	got, err := haDB.GetHealthAuthority(ctx, want.Issuer)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if diff := cmp.Diff(want, got); diff != "" {
+		t.Fatalf("mismatch (-want, +got):\n%s", diff)
+	}
+}
diff --git a/internal/verification/model/health_authority_test.go b/internal/verification/model/health_authority_test.go
new file mode 100644
index 0000000..7d4e647
--- /dev/null
+++ b/internal/verification/model/health_authority_test.go
@@ -0,0 +1,122 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package model
+
+import (
+	"strings"
+	"testing"
+	"time"
+)
+
+func TestIsValid(t *testing.T) {
+	now := time.Now().UTC()
+	cases := []struct {
+		name  string
+		from  time.Time
+		thru  time.Time
+		valid bool
+	}{
+		{
+			name:  "valid now",
+			from:  now.Add(-1 * time.Minute),
+			thru:  now.Add(1 * time.Minute),
+			valid: true,
+		},
+		{
+			name:  "valid no expiration",
+			from:  now.Add(-1 * time.Minute),
+			thru:  time.Time{},
+			valid: true,
+		},
+		{
+			name:  "not valid yet",
+			from:  now.Add(1 * time.Minute),
+			valid: false,
+		},
+		{
+			name:  "expired",
+			from:  now.Add(-2 * time.Minute),
+			thru:  now.Add(-1 * time.Second),
+			valid: false,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			hak := HealthAuthorityKey{
+				From: tc.from,
+				Thru: tc.thru,
+			}
+			if valid := hak.IsValid(); valid != tc.valid {
+				t.Errorf("IsValid: want: %v got: %v", tc.valid, valid)
+			}
+		})
+	}
+}
+
+func TestPublicKeyParse(t *testing.T) {
+	cases := []struct {
+		name     string
+		pemBlock string
+		msg      string
+	}{
+		{
+			name: "valid PEM",
+			pemBlock: `-----BEGIN PUBLIC KEY-----
+MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEA+k9YktDK3UpOhBIy+O17biuwd/g
+IBSEEHOdgpAynz0yrHpkWL6vxjNHxRdWcImZxPgL0NVHMdY4TlsL7qaxBQ==
+-----END PUBLIC KEY-----`,
+		},
+		{
+			name: "invalid PEM",
+			pemBlock: `-----BEGIN PUBLIC KEY-----
+totally invalid
+-----END PUBLIC KEY-----`,
+			msg: "unable to decode PEM block containing PUBLIC KEY",
+		},
+		{
+			name: "wrong key type",
+			pemBlock: `-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvs3MAjWBFJecFLwT4lhd
+HxXbn7EaVbx3/JgiXG3Q3PCCxEYQq6SRYp/4qJpZJ2nAW+BoMCxZjTBq8bmby3WT
+js5A/G62dLgq5qKRsny6kw2ix3tFXb0I9TsPSUieVmxPgioFF1ytvIU7wKQ07vAZ
+HW05DlJJM3E9WhB/ZVKl9NmVp01CcojfhmENPNu65XaAWEMp4txyyX7rU8iPPSsK
+QCmoWZQ6r1E1r5+/RumIobbwdYxax3esvC4B3W2jyLFqMJGVBrhWf7tDki/3mCub
+NTG3+oqI0Q6a3kPOuAAAupr373j7O1YXrM2KAix966EPwTNlK7YCcJa0m6PKz9DT
+6wIDAQAB
+-----END PUBLIC KEY-----`,
+			msg: "unsupported public key type: *rsa.PublicKey",
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			hak := HealthAuthorityKey{
+				PublicKeyPEM: tc.pemBlock,
+			}
+
+			k, err := hak.PublicKey()
+			if err != nil {
+				if tc.msg == "" {
+					t.Errorf("unexpected error: %v", err)
+				} else if !strings.Contains(err.Error(), tc.msg) {
+					t.Errorf("missing error text: want '%v', got '%v'", tc.msg, err.Error())
+				}
+			} else if k == nil {
+				t.Errorf("ECDSA public key is unexpectedly nil")
+			}
+		})
+	}
+}
diff --git a/internal/verification/phaverify_test.go b/internal/verification/phaverify_test.go
new file mode 100644
index 0000000..8a3cba1
--- /dev/null
+++ b/internal/verification/phaverify_test.go
@@ -0,0 +1,154 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package verification provides the ability to verify the diagnosis certificates
+// (JWTs) coming from public health authorities that are responsible for verifying
+// diagnosis pin codes and ceritfying the TEKs.
+package verification
+
+import (
+	"context"
+	"crypto/ecdsa"
+	"crypto/elliptic"
+	"crypto/rand"
+	"crypto/x509"
+	"encoding/base64"
+	"encoding/pem"
+	"testing"
+	"time"
+
+	"github.com/dgrijalva/jwt-go"
+
+	aamodel "github.com/google/exposure-notifications-server/internal/authorizedapp/model"
+	coredb "github.com/google/exposure-notifications-server/internal/database"
+	pubmodel "github.com/google/exposure-notifications-server/internal/publish/model"
+	"github.com/google/exposure-notifications-server/internal/verification/database"
+	"github.com/google/exposure-notifications-server/internal/verification/model"
+
+	verifyapi "github.com/google/exposure-notifications-server/pkg/api/v1alpha1"
+	utils "github.com/google/exposure-notifications-server/pkg/verification"
+)
+
+// TODO(mikehelmick) : this only tests the happy path. Write non happy tests.
+func TestVerifyCertificate(t *testing.T) {
+	t.Parallel()
+
+	// Generate ECDSA key pair.
+	privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	if err != nil {
+		t.Fatal(err)
+	}
+	publicKey := privateKey.Public()
+
+	// Get the PEM for the public key.
+	x509EncodedPub, err := x509.MarshalPKIXPublicKey(publicKey)
+	if err != nil {
+		t.Fatal(err)
+	}
+	pemEncodedPub := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: x509EncodedPub})
+	pemPublicKey := string(pemEncodedPub)
+
+	// Set up database. Create HealthAuthority + HAKey for the test.
+	testDB := coredb.NewTestDatabase(t)
+	ctx := context.Background()
+	haDB := database.New(testDB)
+
+	healthAuthority := model.HealthAuthority{
+		Issuer:   "doh.my.gov",
+		Audience: "exposure-notifications-server",
+		Name:     "Very Real Health Authority",
+	}
+	if err := haDB.AddHealthAuthority(ctx, &healthAuthority); err != nil {
+		t.Fatal(err)
+	}
+
+	hak := model.HealthAuthorityKey{
+		Version:      "v1",
+		From:         time.Now(),
+		PublicKeyPEM: pemPublicKey,
+	}
+	if err := haDB.AddHealthAuthorityKey(ctx, &healthAuthority, &hak); err != nil {
+		t.Fatal(err)
+	}
+
+	// Build the verification certificate.
+	hmacKeyBytes := make([]byte, 32)
+	if _, err := rand.Read(hmacKeyBytes); err != nil {
+		t.Fatal(err)
+	}
+	hmacKeyB64 := base64.StdEncoding.EncodeToString(hmacKeyBytes)
+
+	// Fake authorized app.
+	authApp := aamodel.NewAuthorizedApp()
+	authApp.AllowedHealthAuthorityIDs[healthAuthority.ID] = struct{}{}
+
+	// Build a sample certificate.
+	publish := pubmodel.Publish{
+		Keys: []pubmodel.ExposureKey{
+			{
+				Key:              "IRgYIhYiy4WMl9z68bMk6w==",
+				IntervalNumber:   2650032,
+				IntervalCount:    144,
+				TransmissionRisk: 4,
+			},
+			{
+				Key:              "dPCphLzfG4uzXneNimkPRQ====",
+				IntervalNumber:   2650032 + 144,
+				IntervalCount:    144,
+				TransmissionRisk: 4,
+			},
+			{
+				Key:              "5AUyPfJKcg5cr3OgKdR8Sw==",
+				IntervalNumber:   2650032 + 144*2,
+				IntervalCount:    144,
+				TransmissionRisk: 4,
+			},
+		},
+	}
+
+	// Calculate the HMAC.
+	hmac, err := utils.CalculateExposureKeyHMAC(publish.Keys, hmacKeyBytes)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	claims := verifyapi.NewVerificationClaims()
+	claims.Audience = "exposure-notifications-server"
+	claims.Issuer = "doh.my.gov"
+	claims.IssuedAt = time.Now().UTC().Unix()
+	claims.ExpiresAt = time.Now().UTC().Add(5 * time.Minute).Unix()
+	claims.SignedMAC = base64.StdEncoding.EncodeToString(hmac) // would be generated on the client and passed through.
+	claims.KeyVersion = "v1"                                   // matches the key configured above.
+	// leaves PHAClaims and transmission risk overrides out of it.
+
+	token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
+	jwtText, err := token.SignedString(privateKey)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// Insert this data into the publish request.
+	publish.VerificationPayload = jwtText
+	publish.HMACKey = hmacKeyB64
+
+	// Actually test the verify code.
+	verifier := New(haDB)
+	overrides, err := verifier.VerifyDiagnosisCertificate(ctx, authApp, &publish)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(overrides) != 0 {
+		t.Errorf("wanted no overrides, got %v", overrides)
+	}
+}
diff --git a/pkg/api/v1alpha1/verification_types_test.go b/pkg/api/v1alpha1/verification_types_test.go
new file mode 100644
index 0000000..0ef8a55
--- /dev/null
+++ b/pkg/api/v1alpha1/verification_types_test.go
@@ -0,0 +1,37 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package v1alpha1
+
+import (
+	"sort"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+)
+
+func TestTransmissionRiskVectorSort(t *testing.T) {
+	got := TransmissionRiskVector{
+		{0, 0},
+		{3, 100},
+		{5, 200},
+	}
+	sort.Sort(got)
+
+	want := TransmissionRiskVector{{5, 200}, {3, 100}, {0, 0}}
+
+	if diff := cmp.Diff(want, got); diff != "" {
+		t.Errorf("sort(TransmissionRiskVector) mismatch (-want +got):\n%v", diff)
+	}
+}
diff --git a/pkg/verification/utils_test.go b/pkg/verification/utils_test.go
new file mode 100644
index 0000000..6079374
--- /dev/null
+++ b/pkg/verification/utils_test.go
@@ -0,0 +1,54 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package verification
+
+import (
+	"encoding/base64"
+	"testing"
+
+	"github.com/google/exposure-notifications-server/internal/publish/model"
+)
+
+func TestCalculateHac(t *testing.T) {
+	secret := []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
+
+	eKeys := []model.ExposureKey{
+		{
+			Key:              "z2Cx9hdz2SlxZ8GEgqTYpA==",
+			IntervalNumber:   1,
+			IntervalCount:    144,
+			TransmissionRisk: 3,
+		},
+		{
+			Key:              "dPCphLzfG4uzXneNimkPRQ==",
+			IntervalNumber:   144,
+			IntervalCount:    144,
+			TransmissionRisk: 5,
+		},
+	}
+
+	mac, err := CalculateExposureKeyHMAC(eKeys, secret)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	got := base64.StdEncoding.EncodeToString(mac)
+	want := "2u1nHt5WWurJytFLF3xitNzM99oNrad2y4YGOL53AeY="
+	// Normally, to verify we would calculate this again, and verify with
+	// hmac.Equals. This is just verifying the calculation code in this package.
+	if got != want {
+		t.Fatalf("incorrect mac, want: %v, got %v", want, mac)
+	}
+}
diff --git a/testdata/clear-export.sql b/testdata/clear-export.sql
new file mode 100644
index 0000000..7eeef30
--- /dev/null
+++ b/testdata/clear-export.sql
@@ -0,0 +1,5 @@
+BEGIN;
+
+TRUNCATE exportfile, exportbatch, exposure CASCADE;
+
+END;
\ No newline at end of file
diff --git a/testing/enclient/enclient.go b/testing/enclient/enclient.go
index 837a97b..b0ce497 100644
--- a/testing/enclient/enclient.go
+++ b/testing/enclient/enclient.go
@@ -22,7 +22,7 @@ type Interval int32
 // Posts requests to the specified url.
 // This methods attempts to serialize data argument as a json.
 func PostRequest(url string, data interface{}) (*http.Response, error) {
-	request := bytes.NewBuffer(JsonRequest(data))
+	request := bytes.NewBuffer(JSONRequest(data))
 	r, err := http.NewRequest("POST", url, request)
 	if err != nil {
 		return nil, err
@@ -48,7 +48,7 @@ func PostRequest(url string, data interface{}) (*http.Response, error) {
 }
 
 // Serializes the given argument to json.
-func JsonRequest(data interface{}) []byte {
+func JSONRequest(data interface{}) []byte {
 	jsonData, err := json.Marshal(data)
 	if err != nil {
 		log.Fatalf("unable to marshal json payload")