Repository: google/exposure-notifications-server
Pull request #558 (2020-06-05 20:37:08)

fix(signing): add configuration option for azure key vault
The code to support signing with Azure Key Vault was added in #527, but it was not added as a startup configuration option, this simply fixes that.

googlebot: 
Thanks for your pull request. It looks like this may be your first contribution to a Google open source project (if not, look below for help). Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

:memo: **Please visit <https://cla.developers.google.com/> to sign.**

Once you've signed (or fixed any issues), please reply here with `@googlebot I signed it!` and we'll verify it.

----

#### What to do if you already signed the CLA

##### Individual signers

*   It's possible we don't have your GitHub username or you're using a different email address on your commit. Check [your existing CLA data](https://cla.developers.google.com/clas) and verify that your [email is set on your git commits](https://help.github.com/articles/setting-your-email-in-git/).

##### Corporate signers

*   Your company has a Point of Contact who decides which employees are authorized to participate. Ask your POC to be added to the group of authorized contributors. If you don't know who your Point of Contact is, direct the Google project maintainer to [go/cla#troubleshoot](http://go/cla#troubleshoot) ([Public version](https://opensource.google/docs/cla/#troubleshoot)).
*   The email used to register you as an authorized contributor must be the email used for the Git commit. Check [your existing CLA data](https://cla.developers.google.com/clas) and verify that your [email is set on your git commits](https://help.github.com/articles/setting-your-email-in-git/).
*   The email used to register you as an authorized contributor must also be [attached to your GitHub account](https://github.com/settings/emails).
		

ℹ️ **Googlers: [Go here](https://goto.google.com/prinfo/https%3A%2F%2Fgithub.com%2Fgoogle%2Fexposure-notifications-server%2Fpull%2F558) for more info**.

<!-- need_sender_cla -->
google-oss-robot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by: *<a href="https://github.com/google/exposure-notifications-server/pull/558#" title="Author self-approved">ultimateboy</a>*
To complete the [pull request process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process), please assign **sethvargo**
You can assign the PR to them by writing `/assign @sethvargo` 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":["sethvargo"]} -->
google-oss-robot: Hi @ultimateboy. Thanks for your PR.

I'm waiting for a [google](https://github.com/orgs/google/people) member to verify that this patch is reasonable to test. If it is, they should reply with `/ok-to-test` on its own line. Until that is done, I will not automatically test new commits in this PR, but the usual testing commands by org members will still work. Regular contributors should [join the org](https://github.com/orgs/google/people) to skip this step.

Once the patch is verified, the new status will be reflected by the `ok-to-test` label.

I understand the commands that are listed [here](https://go.k8s.io/bot-commands?repo=google%2Fexposure-notifications-server).

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes/test-infra](https://github.com/kubernetes/test-infra/issues/new?title=Prow%20issue:) repository.
</details>

mikehelmick: /ok-to-test
sethvargo: Hey @ultimateboy thanks for the PR! Let us know if you have any questions about the CLA signing bits. 
ultimateboy: @sethvargo Will do. Might take a little bit of time to figure out those details. But I'm on it :)
sethvargo: Got it. If other folks plan to contribute, they can start the process early at https://cla.developers.google.com/ before opening a PR. Good news is that it's a one-time process.
google-oss-robot: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/google/exposure-notifications-server/pull/558#pullrequestreview-425618078" title="Approved">sethvargo</a>*, *<a href="https://github.com/google/exposure-notifications-server/pull/558#" title="Author self-approved">ultimateboy</a>*

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

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

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

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

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":[]} -->

Patch:
diff --git a/.github/SECURITY.md b/.github/SECURITY.md
new file mode 100644
index 0000000..08b3ff1
--- /dev/null
+++ b/.github/SECURITY.md
@@ -0,0 +1,5 @@
+# Security Policy
+
+To report a suspected vulnerability, please contact
+exposure-notifications-feedback@google.com and include the steps to
+produce the vulnerability.
diff --git a/docs/.gitignore b/docs/.gitignore
new file mode 100644
index 0000000..eabac97
--- /dev/null
+++ b/docs/.gitignore
@@ -0,0 +1,5 @@
+_site
+.sass-cache
+.jekyll-cache/
+.jekyll-metadata
+.DS_Store
\ No newline at end of file
diff --git a/docs/Gemfile b/docs/Gemfile
new file mode 100644
index 0000000..72c0e38
--- /dev/null
+++ b/docs/Gemfile
@@ -0,0 +1,27 @@
+source "https://rubygems.org"
+
+# Hello! This is where you manage which Jekyll version is used to run.
+# When you want to use a different version, change it below, save the
+# file and run `bundle install`. Run Jekyll with `bundle exec`, like so:
+#
+#     bundle exec jekyll serve
+#
+# This will help ensure the proper Jekyll version is running.
+# Happy Jekylling!
+
+# This is the default theme for new Jekyll sites. You may change this to anything you like.
+# If you want to use GitHub Pages, remove the "gem "jekyll"" above and
+# uncomment the line below. To upgrade, run `bundle update github-pages`.
+gem "github-pages", group: :jekyll_plugins
+
+# If you have any plugins, put them here!
+group :jekyll_plugins do
+
+end
+
+# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
+gem "tzinfo-data", platforms: [:mingw, :mswin, :x64_mingw, :jruby]
+
+# Performance-booster for watching directories on Windows
+gem "wdm", "~> 0.1.0" if Gem.win_platform?
+
diff --git a/docs/_config.yml b/docs/_config.yml
new file mode 100644
index 0000000..f58d78e
--- /dev/null
+++ b/docs/_config.yml
@@ -0,0 +1,3 @@
+title: Exposure Notifications Server
+theme: jekyll-theme-minimal
+  
\ No newline at end of file
diff --git a/docs/_data/navigation.yml b/docs/_data/navigation.yml
new file mode 100644
index 0000000..1708d2b
--- /dev/null
+++ b/docs/_data/navigation.yml
@@ -0,0 +1,21 @@
+# documentation links
+docs:
+    - title: Home
+      url: /
+
+    - title: Getting Started
+      children:
+        - title: How to Deploy
+          url: /deploying
+
+    - title: Design
+      children:
+        - title: Server Deployment Options
+          url: /server_deployment_options   
+        - title: Server Functional Requirements
+          url: /server_functional_requirements   
+        - title: "Verification Protocol Design"
+          url: /design/verification_protocol
+
+   
+    
\ No newline at end of file
diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html
new file mode 100644
index 0000000..80bbda4
--- /dev/null
+++ b/docs/_layouts/default.html
@@ -0,0 +1,53 @@
+<!DOCTYPE html>
+<html lang="{{ site.lang | default: "en-US" }}">
+  <head>
+    <meta charset="UTF-8">
+    <meta http-equiv="X-UA-Compatible" content="IE=edge">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+
+{% seo %}
+    <link rel="stylesheet" href="{{ "/assets/css/style.css?v=" | append: site.github.build_revision | relative_url }}">
+    <!--[if lt IE 9]>
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/html5shiv/3.7.3/html5shiv.min.js"></script>
+    <![endif]-->
+  </head>
+  <body>
+    <div class="wrapper">
+      <header>
+        <h1><a href="{{ "/" | absolute_url }}">{{ site.title | default: site.github.repository_name }}</a></h1>
+
+        <!-- Render Navigation -->
+        {% for item in site.data.navigation.docs %}
+            {% if item.url %}
+            <h4><a href="{{ item.url }}">{{ item.title }}</a></h4>
+            {% else %}
+            <h4>{{ item.title }}</h4>
+            {% endif %}
+
+            {% for child in item.children %}
+                <h5>&nbsp;&nbsp;&nbsp;&nbsp;<a href="{{ child.url }}">{{ child.title }}</a></h5>
+            {% endfor %}
+        {% endfor %}
+      </header>
+      <section>
+        
+
+      {{ content }}
+
+      </section>
+      <footer>
+      </footer>
+    </div>
+    <script src="{{ "/assets/js/scale.fix.js" | relative_url }}"></script>
+    {% if site.google_analytics %}
+    <script>
+      (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
+      (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
+      m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
+      })(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
+      ga('create', '{{ site.google_analytics }}', 'auto');
+      ga('send', 'pageview');
+    </script>
+    {% endif %}
+  </body>
+</html>
\ No newline at end of file
diff --git a/docs/deploying.md b/docs/deploying.md
index c8705fc..b122caa 100644
--- a/docs/deploying.md
+++ b/docs/deploying.md
@@ -1,3 +1,6 @@
+---
+layout: default
+---
 <!-- TOC depthFrom:2 depthTo:6 orderedList:false updateOnSave:true withLinks:true -->
 
 - [Before you begin](#before-you-begin)
@@ -87,7 +90,7 @@ following values:
     build, or "all" to build all. See the list of services in the table above.
 
 -   `TAG` (optional) - tag to use for the images. If not specified, it uses a
-    datetime-based tag of the format YYYYMMDDhhmmss.
+    git-sha-based short tag.
 
 ```text
 PROJECT_ID="my-project" \
@@ -110,7 +113,7 @@ specifying the following values:
     deploy, or "all" to deploy all. Note, if you specify multiple services, they
     must use the same tag.
 
--   `TAG` (required) - tag of the deployed image (e.g. YYYYMMDDhhmmss).
+-   `TAG` (required) - tag of the deployed image (e.g. abcd1234).
 
 ```text
 PROJECT_ID="my-project" \
diff --git a/docs/design/verification_protocol.md b/docs/design/verification_protocol.md
index ba1acd7..4362732 100644
--- a/docs/design/verification_protocol.md
+++ b/docs/design/verification_protocol.md
@@ -1,3 +1,6 @@
+---
+layout: default
+---
 # Public Health Authority Diagnosis Verification Protocol
 
 This design covers the exposure notifications server's ability to verify
diff --git a/docs/index.md b/docs/index.md
index 77c8d20..8f532fe 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,3 +1,6 @@
+---
+layout: default
+---
 # Exposure Notification Reference Server
 
 [COVID‑19 Exposure Notifications API](https://www.google.com/covid19/exposurenotifications/)
diff --git a/docs/server_deployment_options.md b/docs/server_deployment_options.md
index bb1d997..933f0b2 100644
--- a/docs/server_deployment_options.md
+++ b/docs/server_deployment_options.md
@@ -1,3 +1,6 @@
+---
+layout: default
+---
 # Google Exposure Notification Server
 
 ## Server deployment options
diff --git a/docs/server_functional_requirements.md b/docs/server_functional_requirements.md
index 25f7cb8..acc7397 100644
--- a/docs/server_functional_requirements.md
+++ b/docs/server_functional_requirements.md
@@ -1,3 +1,7 @@
+---
+layout: default
+classes: wide
+---
 # Google Exposure Notification Server
 
 ## Functional requirements
diff --git a/go.mod b/go.mod
index 89dd60f..afe42fd 100644
--- a/go.mod
+++ b/go.mod
@@ -57,7 +57,7 @@ require (
 	github.com/posener/complete v1.2.3 // indirect
 	github.com/prometheus/client_golang v1.6.0 // indirect
 	github.com/prometheus/common v0.10.0 // indirect
-	github.com/sethvargo/go-envconfig v0.1.0
+	github.com/sethvargo/go-envconfig v0.1.1
 	github.com/sethvargo/go-gcpkms v0.0.0-20200417004547-e50d0c7083d9
 	github.com/shirou/gopsutil v2.20.4+incompatible // indirect
 	github.com/shopspring/decimal v0.0.0-20200419222939-1884f454f8ea // indirect
diff --git a/go.sum b/go.sum
index 73e5a0c..7dfd209 100644
--- a/go.sum
+++ b/go.sum
@@ -1073,8 +1073,8 @@ github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUt
 github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
 github.com/securego/gosec v0.0.0-20191002120514-e680875ea14d h1:BzRvVq1EHuIjxpijCEKpAxzKUUMurOQ4sknehIATRh8=
 github.com/securego/gosec v0.0.0-20191002120514-e680875ea14d/go.mod h1:w5+eXa0mYznDkHaMCXA4XYffjlH+cy1oyKbfzJXa2Do=
-github.com/sethvargo/go-envconfig v0.1.0 h1:Mu2lgEHe12szerlqm4n58KsP5iJljo+o3jALYKjc4Dc=
-github.com/sethvargo/go-envconfig v0.1.0/go.mod h1:XZ2JRR7vhlBEO5zMmOpLgUhgYltqYqq4d4tKagtPUv0=
+github.com/sethvargo/go-envconfig v0.1.1 h1:zgzMUhULxZxMc4t7rPPNjAEKYb/mjbNs/23wWHH6IeU=
+github.com/sethvargo/go-envconfig v0.1.1/go.mod h1:XZ2JRR7vhlBEO5zMmOpLgUhgYltqYqq4d4tKagtPUv0=
 github.com/sethvargo/go-gcpkms v0.0.0-20200417004547-e50d0c7083d9 h1:RhVXqKbTmy4akECFXjhvw+5E/h6woQXzTaj0Yt2mzjM=
 github.com/sethvargo/go-gcpkms v0.0.0-20200417004547-e50d0c7083d9/go.mod h1:C7JiMBZbJnohGWYmx0vi6mQNGgLxhQX/SWxe0wYxf18=
 github.com/shirou/gopsutil v0.0.0-20190901111213-e4ec7b275ada/go.mod h1:WWnYX4lzhCH5h/3YBfyVA3VbLYjlMZZAQcW9ojMexNc=
diff --git a/internal/admin/siginfo/form.go b/internal/admin/siginfo/form.go
index 9508291..efeb0c7 100644
--- a/internal/admin/siginfo/form.go
+++ b/internal/admin/siginfo/form.go
@@ -27,8 +27,6 @@ type formData struct {
 	EndTime           string `form:"endtime"`
 	SigningKeyID      string `form:"SigningKeyID"`
 	SigningKeyVersion string `form:"SigningKeyVersion"`
-	AppPackageName    string `form:"AppPackageName"`
-	BundleID          string `form:"BundleID"`
 }
 
 func (f *formData) EndTimestamp() (time.Time, error) {
@@ -42,8 +40,6 @@ func (f *formData) PopulateSigInfo(si *model.SignatureInfo) error {
 	}
 
 	si.SigningKey = f.SigningKey
-	si.AppPackageName = f.AppPackageName
-	si.BundleID = f.BundleID
 	si.SigningKeyVersion = f.SigningKeyVersion
 	si.SigningKeyID = f.SigningKeyID
 	si.EndTimestamp = ts
diff --git a/internal/authorizedapp/database_provider.go b/internal/authorizedapp/database_provider.go
index 185ed1d..752905d 100644
--- a/internal/authorizedapp/database_provider.go
+++ b/internal/authorizedapp/database_provider.go
@@ -122,3 +122,8 @@ func (p *DatabaseProvider) loadAuthorizedAppFromDatabase(ctx context.Context, na
 	}
 	return config, nil
 }
+
+// Add adds a new authorized app to the system.
+func (p *DatabaseProvider) Add(ctx context.Context, app *model.AuthorizedApp) error {
+	return authorizedappdb.New(p.database).InsertAuthorizedApp(ctx, app)
+}
diff --git a/internal/authorizedapp/memory_provider.go b/internal/authorizedapp/memory_provider.go
index cd11d2e..b6d6ff7 100644
--- a/internal/authorizedapp/memory_provider.go
+++ b/internal/authorizedapp/memory_provider.go
@@ -16,7 +16,9 @@ package authorizedapp
 
 import (
 	"context"
+	"fmt"
 	"strings"
+	"sync"
 
 	"github.com/google/exposure-notifications-server/internal/authorizedapp/model"
 )
@@ -27,26 +29,44 @@ var _ Provider = (*MemoryProvider)(nil)
 // MemoryProvider is an Provider that stores values in-memory. It is primarily
 // used for testing.
 type MemoryProvider struct {
-	Data map[string]*model.AuthorizedApp
+	lock sync.RWMutex
+	data map[string]*model.AuthorizedApp
 }
 
-// NewMemoryProvider creates a new Provider that reads from a database.
-func NewMemoryProvider(ctx context.Context, _ *Config) (Provider, error) {
+// NewMemoryProvider creates a new Provider that is in memory.
+func NewMemoryProvider(_ context.Context, _ *Config) (Provider, error) {
 	provider := &MemoryProvider{
-		Data: make(map[string]*model.AuthorizedApp),
+		data: make(map[string]*model.AuthorizedApp),
 	}
-
 	return provider, nil
 }
 
 // AppConfig returns the config for the given app package name.
-func (p *MemoryProvider) AppConfig(ctx context.Context, name string) (*model.AuthorizedApp, error) {
+func (p *MemoryProvider) AppConfig(_ context.Context, name string) (*model.AuthorizedApp, error) {
+	p.lock.RLock()
+	defer p.lock.RUnlock()
+
 	// Match case-insensitivy of the database.
 	name = strings.ToLower(name)
 
-	val, ok := p.Data[name]
+	val, ok := p.data[name]
 	if !ok {
 		return nil, ErrAppNotFound
 	}
 	return val, nil
 }
+
+// Add inserts the app. It returns an error if the app already exists.
+func (p *MemoryProvider) Add(_ context.Context, app *model.AuthorizedApp) error {
+	p.lock.Lock()
+	defer p.lock.Unlock()
+
+	name := strings.ToLower(app.AppPackageName)
+
+	if _, ok := p.data[name]; ok {
+		return fmt.Errorf("%v already exists", name)
+	}
+
+	p.data[name] = app
+	return nil
+}
diff --git a/internal/authorizedapp/provider.go b/internal/authorizedapp/provider.go
index 8706585..dcf1c89 100644
--- a/internal/authorizedapp/provider.go
+++ b/internal/authorizedapp/provider.go
@@ -33,5 +33,8 @@ type Provider interface {
 	//
 	// The name field is case-insensitive. Implementers should adjust accordingly
 	// to handle mixed case. com.MyApp is the same as com.myapp.
-	AppConfig(ctx context.Context, name string) (*model.AuthorizedApp, error)
+	AppConfig(context.Context, string) (*model.AuthorizedApp, error)
+
+	// Add inserts a model into the provider.
+	Add(context.Context, *model.AuthorizedApp) error
 }
diff --git a/internal/cache/cache.go b/internal/cache/cache.go
index 976f951..d18a0ab 100644
--- a/internal/cache/cache.go
+++ b/internal/cache/cache.go
@@ -86,11 +86,11 @@ func (c *Cache) WriteThruLookup(name string, primaryLookup Func) (interface{}, e
 	defer c.mu.Unlock()
 	// double check that the value hasn't been set by another goroutine.
 	if val, hit := c.data[name]; hit && !val.expired() {
-		return val, nil
+		return val.object, nil
 	}
 	// Either a miss, or hit w/ expired value.
 
-	// Value does indeed need to be refreshed. Used the provided fucntion.
+	// Value does indeed need to be refreshed. Used the provided function.
 	newData, err := primaryLookup()
 	if err != nil {
 		return nil, err
diff --git a/internal/database/config.go b/internal/database/config.go
index 4cb9fd1..4159745 100644
--- a/internal/database/config.go
+++ b/internal/database/config.go
@@ -25,7 +25,7 @@ type Config struct {
 	Port               string        `env:"DB_PORT, default=5432"`
 	SSLMode            string        `env:"DB_SSLMODE, default=require"`
 	ConnectionTimeout  int           `env:"DB_CONNECT_TIMEOUT"`
-	Password           string        `env:"DB_PASSWORD"`
+	Password           string        `env:"DB_PASSWORD" json:"-"` // ignored by zap's JSON formatter
 	SSLCertPath        string        `env:"DB_SSLCERT"`
 	SSLKeyPath         string        `env:"DB_SSLKEY"`
 	SSLRootCertPath    string        `env:"DB_SSLROOTCERT"`
diff --git a/internal/database/connection.go b/internal/database/connection.go
index f21811f..2da6ff8 100644
--- a/internal/database/connection.go
+++ b/internal/database/connection.go
@@ -18,7 +18,6 @@ package database
 import (
 	"context"
 	"fmt"
-	"net/url"
 	"strings"
 	"time"
 
@@ -67,14 +66,6 @@ func dbConnectionString(config *Config) string {
 	return strings.Join(p, " ")
 }
 
-// dbURI builds a Postgres URI suitable for the lib/pq driver, which is used by
-// github.com/golang-migrate/migrate.
-func DbURI(config *Config) string {
-	return fmt.Sprintf("postgres://%s/%s?sslmode=disable&user=%s&password=%s&port=%s",
-		config.Host, config.Name, config.User,
-		url.QueryEscape(config.Password), url.QueryEscape(config.Port))
-}
-
 func setIfNotEmpty(m map[string]string, key, val string) {
 	if val != "" {
 		m[key] = val
diff --git a/internal/database/database_util.go b/internal/database/database_util.go
index a977ec0..5e9488f 100644
--- a/internal/database/database_util.go
+++ b/internal/database/database_util.go
@@ -39,9 +39,9 @@ import (
 	// imported to register the "postgres" database driver for migrate
 )
 
-// NewTestDatabase creates a new database suitable for use in testing. This
-// should not be used outside of testing, but it is exposed in the main package
-// so it can be shared with other packages.
+// NewTestDatabaseWithConfig creates a new database suitable for use in testing.
+// This should not be used outside of testing, but it is exposed in the main
+// package so it can be shared with other packages.
 //
 // All database tests can be skipped by running `go test -short` or by setting
 // the `SKIP_DATABASE_TESTS` environment variable.
@@ -105,6 +105,10 @@ func NewTestDatabaseWithConfig(tb testing.TB) (*DB, *Config) {
 	q.Add("sslmode", "disable")
 	connURL.RawQuery = q.Encode()
 
+	// Wait for the container to start - we'll retry connections in a loop below,
+	// but there's no point in trying immediately.
+	time.Sleep(1 * time.Second)
+
 	// Establish a connection to the database. Use a Fibonacci backoff instead of
 	// exponential so wait times scale appropriately.
 	var dbpool *pgxpool.Pool
diff --git a/internal/export/database/export.go b/internal/export/database/export.go
index c66bf0b..0e16ba6 100644
--- a/internal/export/database/export.go
+++ b/internal/export/database/export.go
@@ -207,11 +207,11 @@ func (db *ExportDB) AddSignatureInfo(ctx context.Context, si *model.SignatureInf
 		row := tx.QueryRow(ctx, `
 			INSERT INTO
  				SignatureInfo
-				(signing_key, app_package_name, bundle_id, signing_key_version, signing_key_id, thru_timestamp)
+				(signing_key, signing_key_version, signing_key_id, thru_timestamp)
 			VALUES
-				($1, LOWER($2), LOWER($3), $4, $5, $6)
+				($1, $2, $3, $4)
 			RETURNING id
-			`, si.SigningKey, si.AppPackageName, si.BundleID, si.SigningKeyVersion, si.SigningKeyID, thru)
+			`, si.SigningKey, si.SigningKeyVersion, si.SigningKeyID, thru)
 
 		if err := row.Scan(&si.ID); err != nil {
 			return fmt.Errorf("fetching id: %w", err)
@@ -229,11 +229,13 @@ func (db *ExportDB) UpdateSignatureInfo(ctx context.Context, si *model.Signature
 		result, err := tx.Exec(ctx, `
 			UPDATE SignatureInfo
 			SET
-				signing_key = $1, app_package_name = LOWER($2), bundle_id = LOWER($3),
-				signing_key_version = $4, signing_key_id = $5, thru_timestamp = $6
+				signing_key = $1,
+				signing_key_version = $2,
+				signing_key_id = $3,
+				thru_timestamp = $4
 			WHERE
-				id = $7
- 			`, si.SigningKey, si.AppPackageName, si.BundleID, si.SigningKeyVersion, si.SigningKeyID, thru, si.ID)
+				id = $5
+ 			`, si.SigningKey, si.SigningKeyVersion, si.SigningKeyID, thru, si.ID)
 		if err != nil {
 			return fmt.Errorf("updating signatureinfo: %w", err)
 		}
@@ -253,10 +255,10 @@ func (db *ExportDB) ListAllSigntureInfos(ctx context.Context) ([]*model.Signatur
 
 	rows, err := conn.Query(ctx, `
     SELECT
-      id, signing_key, LOWER(app_package_name), LOWER(bundle_id), signing_key_version, signing_key_id, thru_timestamp
+      id, signing_key, signing_key_version, signing_key_id, thru_timestamp
     FROM
       SignatureInfo
-    ORDER BY signing_key_id ASC, signing_key_version ASC, thru_timestamp DESC, LOWER(app_package_name) ASC, LOWER(bundle_id) ASC
+    ORDER BY signing_key_id ASC, signing_key_version ASC, thru_timestamp DESC
   `)
 	if err != nil {
 		return nil, err
@@ -286,7 +288,7 @@ func (db *ExportDB) LookupSignatureInfos(ctx context.Context, ids []int64, valid
 
 	rows, err := conn.Query(ctx, `
     SELECT
-      id, signing_key, LOWER(app_package_name), LOWER(bundle_id), signing_key_version, signing_key_id, thru_timestamp
+      id, signing_key, signing_key_version, signing_key_id, thru_timestamp
     FROM
       SignatureInfo
     WHERE
@@ -321,7 +323,7 @@ func (db *ExportDB) GetSignatureInfo(ctx context.Context, id int64) (*model.Sign
 
 	row := conn.QueryRow(ctx, `
 		SELECT
-			id, signing_key, LOWER(app_package_name), LOWER(bundle_id), signing_key_version, signing_key_id, thru_timestamp
+			id, signing_key, signing_key_version, signing_key_id, thru_timestamp
 		FROM
 			SignatureInfo
 		WHERE
@@ -334,7 +336,7 @@ func (db *ExportDB) GetSignatureInfo(ctx context.Context, id int64) (*model.Sign
 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 {
+	if err := row.Scan(&info.ID, &info.SigningKey, &info.SigningKeyVersion, &info.SigningKeyID, &thru); err != nil {
 		return nil, err
 	}
 	if thru != nil {
@@ -401,7 +403,7 @@ func (db *ExportDB) AddExportBatches(ctx context.Context, batches []*model.Expor
 }
 
 // LeaseBatch returns a leased ExportBatch for the worker to process. If no work to do, nil will be returned.
-func (db *ExportDB) LeaseBatch(ctx context.Context, ttl time.Duration, now time.Time) (*model.ExportBatch, error) {
+func (db *ExportDB) LeaseBatch(ctx context.Context, ttl time.Duration, batchMaxCloseTime time.Time) (*model.ExportBatch, error) {
 	// Lookup a set of candidate batch IDs.
 	var openBatchIDs []int64
 	err := func() error { // Use a func to allow defer conn.Release() to work.
@@ -427,7 +429,7 @@ func (db *ExportDB) LeaseBatch(ctx context.Context, ttl time.Duration, now time.
 			AND
 				end_timestamp < $3
 			LIMIT 100
-		`, model.ExportBatchOpen, model.ExportBatchPending, now)
+		`, model.ExportBatchOpen, model.ExportBatchPending, batchMaxCloseTime)
 		if err != nil {
 			return err
 		}
@@ -475,7 +477,7 @@ func (db *ExportDB) LeaseBatch(ctx context.Context, ttl time.Duration, now time.
 				return err
 			}
 
-			if status == model.ExportBatchComplete || (expires != nil && status == model.ExportBatchPending && now.Before(*expires)) {
+			if status == model.ExportBatchComplete || (expires != nil && status == model.ExportBatchPending && batchMaxCloseTime.Before(*expires)) {
 				// Something beat us to this batch, it's no longer available.
 				return nil
 			}
@@ -487,7 +489,7 @@ func (db *ExportDB) LeaseBatch(ctx context.Context, ttl time.Duration, now time.
 					status = $1, lease_expires = $2
 				WHERE
 				    batch_id = $3
-				`, model.ExportBatchPending, now.Add(ttl), bid)
+				`, model.ExportBatchPending, batchMaxCloseTime.Add(ttl), bid)
 			if err != nil {
 				return err
 			}
diff --git a/internal/export/exportfile.go b/internal/export/exportfile.go
index b217773..fa1af85 100644
--- a/internal/export/exportfile.go
+++ b/internal/export/exportfile.go
@@ -180,12 +180,6 @@ func marshalContents(eb *model.ExportBatch, exposures []*publishmodel.Exposure,
 
 func createSignatureInfo(si *model.SignatureInfo) *export.SignatureInfo {
 	sigInfo := &export.SignatureInfo{SignatureAlgorithm: proto.String(algorithm)}
-	if si.AppPackageName != "" {
-		sigInfo.AndroidPackage = proto.String(si.AppPackageName)
-	}
-	if si.BundleID != "" {
-		sigInfo.AppBundleId = proto.String(si.BundleID)
-	}
 	if si.SigningKeyVersion != "" {
 		sigInfo.VerificationKeyVersion = proto.String(si.SigningKeyVersion)
 	}
diff --git a/internal/export/model/export_model.go b/internal/export/model/export_model.go
index ba2e01c..18f9b10 100644
--- a/internal/export/model/export_model.go
+++ b/internal/export/model/export_model.go
@@ -32,15 +32,15 @@ const (
 )
 
 type ExportConfig struct {
-	ConfigID         int64         `db:"config_id"`
-	BucketName       string        `db:"bucket_name"`
-	FilenameRoot     string        `db:"filename_root"`
-	Period           time.Duration `db:"period_seconds"`
-	OutputRegion     string        `db:"output_region"`
-	InputRegions     []string      `db:"input_regions"`
-	From             time.Time     `db:"from_timestamp"`
-	Thru             time.Time     `db:"thru_timestamp"`
-	SignatureInfoIDs []int64       `db:"signature_info_ids"`
+	ConfigID         int64
+	BucketName       string
+	FilenameRoot     string
+	Period           time.Duration
+	OutputRegion     string
+	InputRegions     []string
+	From             time.Time
+	Thru             time.Time
+	SignatureInfoIDs []int64
 }
 
 // EffectiveInputRegions either returns `InputRegions` or if that array is
@@ -94,17 +94,17 @@ func (ec *ExportConfig) ThruHTMLTime() string {
 }
 
 type ExportBatch struct {
-	BatchID          int64     `db:"batch_id" json:"batchID"`
-	ConfigID         int64     `db:"config_id" json:"configID"`
-	BucketName       string    `db:"bucket_name" json:"bucketName"`
-	FilenameRoot     string    `db:"filename_root" json:"filenameRoot"`
-	StartTimestamp   time.Time `db:"start_timestamp" json:"startTimestamp"`
-	EndTimestamp     time.Time `db:"end_timestamp" json:"endTimestamp"`
-	OutputRegion     string    `db:"region" json:"output_region"`
-	InputRegions     []string  `db:"input_regions" json:"inputRegions"`
-	Status           string    `db:"status" json:"status"`
-	LeaseExpires     time.Time `db:"lease_expires" json:"leaseExpires"`
-	SignatureInfoIDs []int64   `db:"signature_info_ids"`
+	BatchID          int64
+	ConfigID         int64
+	BucketName       string
+	FilenameRoot     string
+	StartTimestamp   time.Time
+	EndTimestamp     time.Time
+	OutputRegion     string
+	InputRegions     []string
+	Status           string
+	LeaseExpires     time.Time
+	SignatureInfoIDs []int64
 }
 
 // EffectiveInputRegions either returns `InputRegions` or if that array is
@@ -114,14 +114,14 @@ func (eb *ExportBatch) EffectiveInputRegions() []string {
 }
 
 type ExportFile struct {
-	BucketName   string   `db:"bucket_name"`
-	Filename     string   `db:"filename"`
-	BatchID      int64    `db:"batch_id"`
-	OutputRegion string   `db:"output_region"`
-	InputRegions []string `db:"input_regions" json:"inputRegions"`
-	BatchNum     int      `db:"batch_num"`
-	BatchSize    int      `db:"batch_size"`
-	Status       string   `db:"status"`
+	BucketName   string
+	Filename     string
+	BatchID      int64
+	OutputRegion string
+	InputRegions []string
+	BatchNum     int
+	BatchSize    int
+	Status       string
 }
 
 // EffectiveInputRegions either returns `InputRegions` or if that array is
@@ -131,13 +131,11 @@ func (ef *ExportFile) EffectiveInputRegions() []string {
 }
 
 type SignatureInfo struct {
-	ID                int64     `db:"id"`
-	SigningKey        string    `db:"signing_key"`
-	AppPackageName    string    `db:"app_package_name"`
-	BundleID          string    `db:"bundle_id"`
-	SigningKeyVersion string    `db:"signing_key_version"`
-	SigningKeyID      string    `db:"signing_key_id"`
-	EndTimestamp      time.Time `db:"thru_timestamp"`
+	ID                int64
+	SigningKey        string
+	SigningKeyVersion string
+	SigningKeyID      string
+	EndTimestamp      time.Time
 }
 
 // FormattedEndTimestamp returns the end date for display in the admin console.
diff --git a/internal/export/worker.go b/internal/export/worker.go
index 22663fa..87c1966 100644
--- a/internal/export/worker.go
+++ b/internal/export/worker.go
@@ -60,12 +60,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.
-		minutesAgo := time.Now().Add(-5 * time.Minute)
-
 		// Check for a batch and obtain a lease for it.
-		batch, err := exportDB.LeaseBatch(ctx, s.config.WorkerTimeout, minutesAgo)
+		batch, err := exportDB.LeaseBatch(ctx, s.config.WorkerTimeout, time.Now())
 		if err != nil {
 			logger.Errorf("Failed to lease batch: %v", err)
 			continue
diff --git a/internal/federationin/model/federation_model.go b/internal/federationin/model/federation_model.go
index f34d88f..0669039 100644
--- a/internal/federationin/model/federation_model.go
+++ b/internal/federationin/model/federation_model.go
@@ -20,31 +20,31 @@ import (
 
 // FederationInQuery represents a configuration to pull federation results from other servers.
 type FederationInQuery struct {
-	QueryID        string    `db:"query_id"`
-	ServerAddr     string    `db:"server_addr"`
-	Audience       string    `db:"oidc_audience"`
-	IncludeRegions []string  `db:"include_regions"`
-	ExcludeRegions []string  `db:"exclude_regions"`
-	LastTimestamp  time.Time `db:"last_timestamp"`
+	QueryID        string
+	ServerAddr     string
+	Audience       string
+	IncludeRegions []string
+	ExcludeRegions []string
+	LastTimestamp  time.Time
 }
 
 // FederationInSync is the result of a federation query pulled from other servers.
 type FederationInSync struct {
-	SyncID       int64     `db:"sync_id"`
-	QueryID      string    `db:"query_id"`
-	Started      time.Time `db:"started"`
-	Completed    time.Time `db:"completed"`
-	Insertions   int       `db:"insertions"`
-	MaxTimestamp time.Time `db:"max_timestamp"`
+	SyncID       int64
+	QueryID      string
+	Started      time.Time
+	Completed    time.Time
+	Insertions   int
+	MaxTimestamp time.Time
 }
 
 // FederationOutAuthorization is an authorized client that reads federation data from this server.
 type FederationOutAuthorization struct {
-	Issuer  string `db:"oidc_issuer"`
-	Subject string `db:"oidc_subject"`
+	Issuer  string
+	Subject string
 	// Audience is optional, but will be validated against the OIDC token if provided.
-	Audience       string   `db:"oidc_audience"`
-	Note           string   `db:"note"`
-	IncludeRegions []string `db:"include_regions"`
-	ExcludeRegions []string `db:"exclude_regions"`
+	Audience       string
+	Note           string
+	IncludeRegions []string
+	ExcludeRegions []string
 }
diff --git a/internal/monolith/config.go b/internal/monolith/config.go
deleted file mode 100644
index 1ade931..0000000
--- a/internal/monolith/config.go
+++ /dev/null
@@ -1,68 +0,0 @@
-// 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 monolith
-
-import (
-	"github.com/google/exposure-notifications-server/internal/authorizedapp"
-	"github.com/google/exposure-notifications-server/internal/cleanup"
-	"github.com/google/exposure-notifications-server/internal/database"
-	"github.com/google/exposure-notifications-server/internal/export"
-	"github.com/google/exposure-notifications-server/internal/federationin"
-	"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"
-)
-
-var _ setup.AuthorizedAppConfigProvider = (*Config)(nil)
-var _ setup.BlobstoreConfigProvider = (*Config)(nil)
-var _ setup.DatabaseConfigProvider = (*Config)(nil)
-var _ setup.KeyManagerConfigProvider = (*Config)(nil)
-var _ setup.SecretManagerConfigProvider = (*Config)(nil)
-
-type Config 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 `env:"PORT, default=8080"`
-}
-
-func (c *Config) AuthorizedAppConfig() *authorizedapp.Config {
-	return &c.AuthorizedApp
-}
-
-func (c *Config) BlobstoreConfig() *storage.Config {
-	return &c.Storage
-}
-
-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
-}
diff --git a/internal/monolith/monolith.go b/internal/monolith/monolith.go
deleted file mode 100644
index cb8caf5..0000000
--- a/internal/monolith/monolith.go
+++ /dev/null
@@ -1,90 +0,0 @@
-// 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 monolith runs all the server components at different URL paths.
-package monolith
-
-import (
-	"context"
-	"fmt"
-	"net/http"
-
-	"github.com/google/exposure-notifications-server/internal/cleanup"
-	"github.com/google/exposure-notifications-server/internal/export"
-	"github.com/google/exposure-notifications-server/internal/federationin"
-	"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/server"
-	"github.com/google/exposure-notifications-server/internal/setup"
-
-	// Enable observability with distributed tracing and metrics.
-	_ "github.com/google/exposure-notifications-server/internal/observability"
-)
-
-func RunServer(ctx context.Context) error {
-	logger := logging.FromContext(ctx)
-
-	var config Config
-	env, err := setup.Setup(ctx, &config)
-	if err != nil {
-		return fmt.Errorf("setup.Setup: %w", err)
-	}
-	defer env.Close(ctx)
-
-	mux := http.NewServeMux()
-
-	// Cleanup export
-	cleanupExport, err := cleanup.NewExportHandler(&config.Cleanup, env)
-	if err != nil {
-		return fmt.Errorf("cleanup.NewExportHandler: %w", err)
-	}
-	mux.Handle("/cleanup-export", cleanupExport)
-
-	// Cleanup exposure
-	cleanupExposure, err := cleanup.NewExposureHandler(&config.Cleanup, env)
-	if err != nil {
-		return fmt.Errorf("cleanup.NewExposureHandler: %w", err)
-	}
-	mux.Handle("/cleanup-exposure", cleanupExposure)
-
-	// Export
-	exportServer, err := export.NewServer(&config.Export, env)
-	if err != nil {
-		return fmt.Errorf("export.NewServer: %w", err)
-	}
-	mux.HandleFunc("/export/create-batches", exportServer.CreateBatchesHandler)
-	mux.HandleFunc("/export/do-work", exportServer.WorkerHandler)
-
-	// Federation in
-	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)
-	if err != nil {
-		return fmt.Errorf("publish.NewHandler: %w", err)
-	}
-	mux.HandleFunc("/publish", handlers.WithMinimumLatency(config.Publish.MinRequestDuration, publishServer))
-
-	server, err := server.New(config.Port)
-	if err != nil {
-		return fmt.Errorf("server.New: %w", err)
-	}
-	logger.Infof("listening on :%s", config.Port)
-
-	return server.ServeHTTPHandler(ctx, mux)
-}
diff --git a/internal/pb/export/export.pb.go b/internal/pb/export/export.pb.go
index 376977b..77823a4 100644
--- a/internal/pb/export/export.pb.go
+++ b/internal/pb/export/export.pb.go
@@ -155,11 +155,6 @@ type SignatureInfo struct {
 	sizeCache     protoimpl.SizeCache
 	unknownFields protoimpl.UnknownFields
 
-	// Apple App Store Application Bundle ID
-	AppBundleId *string `protobuf:"bytes,1,opt,name=app_bundle_id,json=appBundleId" json:"app_bundle_id,omitempty"`
-	// Android App package name
-	// Don’t set this if exports are to be consumed by multiple apps.
-	AndroidPackage *string `protobuf:"bytes,2,opt,name=android_package,json=androidPackage" json:"android_package,omitempty"`
 	// Key version for rollovers
 	// Must be in character class [a-zA-Z0-9_]. E.g., 'v1'
 	VerificationKeyVersion *string `protobuf:"bytes,3,opt,name=verification_key_version,json=verificationKeyVersion" json:"verification_key_version,omitempty"`
@@ -204,20 +199,6 @@ func (*SignatureInfo) Descriptor() ([]byte, []int) {
 	return file_internal_pb_export_export_proto_rawDescGZIP(), []int{1}
 }
 
-func (x *SignatureInfo) GetAppBundleId() string {
-	if x != nil && x.AppBundleId != nil {
-		return *x.AppBundleId
-	}
-	return ""
-}
-
-func (x *SignatureInfo) GetAndroidPackage() string {
-	if x != nil && x.AndroidPackage != nil {
-		return *x.AndroidPackage
-	}
-	return ""
-}
-
 func (x *SignatureInfo) GetVerificationKeyVersion() string {
 	if x != nil && x.VerificationKeyVersion != nil {
 		return *x.VerificationKeyVersion
@@ -463,55 +444,53 @@ var file_internal_pb_export_export_proto_rawDesc = []byte{
 	0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x73, 0x12, 0x29, 0x0a, 0x04, 0x6b,
 	0x65, 0x79, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x54, 0x65, 0x6d, 0x70,
 	0x6f, 0x72, 0x61, 0x72, 0x79, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x75, 0x72, 0x65, 0x4b, 0x65, 0x79,
-	0x52, 0x04, 0x6b, 0x65, 0x79, 0x73, 0x22, 0xf7, 0x01, 0x0a, 0x0d, 0x53, 0x69, 0x67, 0x6e, 0x61,
-	0x74, 0x75, 0x72, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x70, 0x70, 0x5f,
-	0x62, 0x75, 0x6e, 0x64, 0x6c, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
-	0x0b, 0x61, 0x70, 0x70, 0x42, 0x75, 0x6e, 0x64, 0x6c, 0x65, 0x49, 0x64, 0x12, 0x27, 0x0a, 0x0f,
-	0x61, 0x6e, 0x64, 0x72, 0x6f, 0x69, 0x64, 0x5f, 0x70, 0x61, 0x63, 0x6b, 0x61, 0x67, 0x65, 0x18,
-	0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x61, 0x6e, 0x64, 0x72, 0x6f, 0x69, 0x64, 0x50, 0x61,
-	0x63, 0x6b, 0x61, 0x67, 0x65, 0x12, 0x38, 0x0a, 0x18, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x63,
-	0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f,
-	0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x16, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x63,
-	0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4b, 0x65, 0x79, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12,
-	0x2e, 0x0a, 0x13, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f,
-	0x6b, 0x65, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x11, 0x76, 0x65,
-	0x72, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4b, 0x65, 0x79, 0x49, 0x64, 0x12,
-	0x2f, 0x0a, 0x13, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x5f, 0x61, 0x6c, 0x67,
-	0x6f, 0x72, 0x69, 0x74, 0x68, 0x6d, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x73, 0x69,
-	0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x41, 0x6c, 0x67, 0x6f, 0x72, 0x69, 0x74, 0x68, 0x6d,
-	0x22, 0xd8, 0x01, 0x0a, 0x14, 0x54, 0x65, 0x6d, 0x70, 0x6f, 0x72, 0x61, 0x72, 0x79, 0x45, 0x78,
-	0x70, 0x6f, 0x73, 0x75, 0x72, 0x65, 0x4b, 0x65, 0x79, 0x12, 0x19, 0x0a, 0x08, 0x6b, 0x65, 0x79,
-	0x5f, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x6b, 0x65, 0x79,
-	0x44, 0x61, 0x74, 0x61, 0x12, 0x36, 0x0a, 0x17, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6d, 0x69, 0x73,
-	0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x72, 0x69, 0x73, 0x6b, 0x5f, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18,
-	0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x15, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6d, 0x69, 0x73, 0x73,
-	0x69, 0x6f, 0x6e, 0x52, 0x69, 0x73, 0x6b, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x41, 0x0a, 0x1d,
-	0x72, 0x6f, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x5f, 0x73, 0x74, 0x61, 0x72, 0x74, 0x5f, 0x69, 0x6e,
-	0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x03, 0x20,
-	0x01, 0x28, 0x05, 0x52, 0x1a, 0x72, 0x6f, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x53, 0x74, 0x61, 0x72,
-	0x74, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x12,
-	0x2a, 0x0a, 0x0e, 0x72, 0x6f, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x5f, 0x70, 0x65, 0x72, 0x69, 0x6f,
-	0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x3a, 0x03, 0x31, 0x34, 0x34, 0x52, 0x0d, 0x72, 0x6f,
-	0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x50, 0x65, 0x72, 0x69, 0x6f, 0x64, 0x22, 0x41, 0x0a, 0x10, 0x54,
-	0x45, 0x4b, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x4c, 0x69, 0x73, 0x74, 0x12,
-	0x2d, 0x0a, 0x0a, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x18, 0x01, 0x20,
-	0x03, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x54, 0x45, 0x4b, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75,
-	0x72, 0x65, 0x52, 0x0a, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x22, 0x9f,
-	0x01, 0x0a, 0x0c, 0x54, 0x45, 0x4b, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x12,
-	0x35, 0x0a, 0x0e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x5f, 0x69, 0x6e, 0x66,
-	0x6f, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74,
-	0x75, 0x72, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0d, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75,
-	0x72, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1b, 0x0a, 0x09, 0x62, 0x61, 0x74, 0x63, 0x68, 0x5f,
-	0x6e, 0x75, 0x6d, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x62, 0x61, 0x74, 0x63, 0x68,
-	0x4e, 0x75, 0x6d, 0x12, 0x1d, 0x0a, 0x0a, 0x62, 0x61, 0x74, 0x63, 0x68, 0x5f, 0x73, 0x69, 0x7a,
-	0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x62, 0x61, 0x74, 0x63, 0x68, 0x53, 0x69,
-	0x7a, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18,
-	0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65,
-	0x42, 0x4b, 0x5a, 0x49, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67,
-	0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x65, 0x78, 0x70, 0x6f, 0x73, 0x75, 0x72, 0x65, 0x2d, 0x6e,
-	0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x2d, 0x73, 0x65, 0x72,
-	0x76, 0x65, 0x72, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x70, 0x62, 0x2f,
-	0x65, 0x78, 0x70, 0x6f, 0x72, 0x74, 0x3b, 0x65, 0x78, 0x70, 0x6f, 0x72, 0x74,
+	0x52, 0x04, 0x6b, 0x65, 0x79, 0x73, 0x22, 0xd6, 0x01, 0x0a, 0x0d, 0x53, 0x69, 0x67, 0x6e, 0x61,
+	0x74, 0x75, 0x72, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x38, 0x0a, 0x18, 0x76, 0x65, 0x72, 0x69,
+	0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x76, 0x65, 0x72,
+	0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x16, 0x76, 0x65, 0x72, 0x69,
+	0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4b, 0x65, 0x79, 0x56, 0x65, 0x72, 0x73, 0x69,
+	0x6f, 0x6e, 0x12, 0x2e, 0x0a, 0x13, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69,
+	0x6f, 0x6e, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52,
+	0x11, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4b, 0x65, 0x79,
+	0x49, 0x64, 0x12, 0x2f, 0x0a, 0x13, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x5f,
+	0x61, 0x6c, 0x67, 0x6f, 0x72, 0x69, 0x74, 0x68, 0x6d, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52,
+	0x12, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x41, 0x6c, 0x67, 0x6f, 0x72, 0x69,
+	0x74, 0x68, 0x6d, 0x4a, 0x04, 0x08, 0x01, 0x10, 0x02, 0x4a, 0x04, 0x08, 0x02, 0x10, 0x03, 0x52,
+	0x0d, 0x61, 0x70, 0x70, 0x5f, 0x62, 0x75, 0x6e, 0x64, 0x6c, 0x65, 0x5f, 0x69, 0x64, 0x52, 0x0f,
+	0x61, 0x6e, 0x64, 0x72, 0x6f, 0x69, 0x64, 0x5f, 0x70, 0x61, 0x63, 0x6b, 0x61, 0x67, 0x65, 0x22,
+	0xd8, 0x01, 0x0a, 0x14, 0x54, 0x65, 0x6d, 0x70, 0x6f, 0x72, 0x61, 0x72, 0x79, 0x45, 0x78, 0x70,
+	0x6f, 0x73, 0x75, 0x72, 0x65, 0x4b, 0x65, 0x79, 0x12, 0x19, 0x0a, 0x08, 0x6b, 0x65, 0x79, 0x5f,
+	0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x6b, 0x65, 0x79, 0x44,
+	0x61, 0x74, 0x61, 0x12, 0x36, 0x0a, 0x17, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6d, 0x69, 0x73, 0x73,
+	0x69, 0x6f, 0x6e, 0x5f, 0x72, 0x69, 0x73, 0x6b, 0x5f, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x02,
+	0x20, 0x01, 0x28, 0x05, 0x52, 0x15, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6d, 0x69, 0x73, 0x73, 0x69,
+	0x6f, 0x6e, 0x52, 0x69, 0x73, 0x6b, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x41, 0x0a, 0x1d, 0x72,
+	0x6f, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x5f, 0x73, 0x74, 0x61, 0x72, 0x74, 0x5f, 0x69, 0x6e, 0x74,
+	0x65, 0x72, 0x76, 0x61, 0x6c, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01,
+	0x28, 0x05, 0x52, 0x1a, 0x72, 0x6f, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x53, 0x74, 0x61, 0x72, 0x74,
+	0x49, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x12, 0x2a,
+	0x0a, 0x0e, 0x72, 0x6f, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x5f, 0x70, 0x65, 0x72, 0x69, 0x6f, 0x64,
+	0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x3a, 0x03, 0x31, 0x34, 0x34, 0x52, 0x0d, 0x72, 0x6f, 0x6c,
+	0x6c, 0x69, 0x6e, 0x67, 0x50, 0x65, 0x72, 0x69, 0x6f, 0x64, 0x22, 0x41, 0x0a, 0x10, 0x54, 0x45,
+	0x4b, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x2d,
+	0x0a, 0x0a, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03,
+	0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x54, 0x45, 0x4b, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72,
+	0x65, 0x52, 0x0a, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x22, 0x9f, 0x01,
+	0x0a, 0x0c, 0x54, 0x45, 0x4b, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x12, 0x35,
+	0x0a, 0x0e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x5f, 0x69, 0x6e, 0x66, 0x6f,
+	0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75,
+	0x72, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0d, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72,
+	0x65, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1b, 0x0a, 0x09, 0x62, 0x61, 0x74, 0x63, 0x68, 0x5f, 0x6e,
+	0x75, 0x6d, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x62, 0x61, 0x74, 0x63, 0x68, 0x4e,
+	0x75, 0x6d, 0x12, 0x1d, 0x0a, 0x0a, 0x62, 0x61, 0x74, 0x63, 0x68, 0x5f, 0x73, 0x69, 0x7a, 0x65,
+	0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x62, 0x61, 0x74, 0x63, 0x68, 0x53, 0x69, 0x7a,
+	0x65, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18, 0x04,
+	0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x42,
+	0x4b, 0x5a, 0x49, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x6f,
+	0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x65, 0x78, 0x70, 0x6f, 0x73, 0x75, 0x72, 0x65, 0x2d, 0x6e, 0x6f,
+	0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x2d, 0x73, 0x65, 0x72, 0x76,
+	0x65, 0x72, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x70, 0x62, 0x2f, 0x65,
+	0x78, 0x70, 0x6f, 0x72, 0x74, 0x3b, 0x65, 0x78, 0x70, 0x6f, 0x72, 0x74,
 }
 
 var (
diff --git a/internal/pb/export/export.proto b/internal/pb/export/export.proto
index 824bd3d..46bcfa2 100644
--- a/internal/pb/export/export.proto
+++ b/internal/pb/export/export.proto
@@ -46,11 +46,10 @@ message TemporaryExposureKeyExport {
 }
 
 message SignatureInfo {
-  // Apple App Store Application Bundle ID
-  optional string app_bundle_id = 1;
-  // Android App package name
-  // Don’t set this if exports are to be consumed by multiple apps.
-  optional string android_package = 2;
+  // No longer need to set these. Old code that still does will be fine
+  // as these were largely noops anyway.
+  reserved 1, 2;
+  reserved "app_bundle_id", "android_package";
   // Key version for rollovers
   // Must be in character class [a-zA-Z0-9_]. E.g., 'v1'
   optional string verification_key_version = 3;
diff --git a/internal/publish/config.go b/internal/publish/config.go
index 9777c27..9cf0257 100644
--- a/internal/publish/config.go
+++ b/internal/publish/config.go
@@ -22,6 +22,7 @@ import (
 	"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/verification"
 )
 
 // Compile-time check to assert this config matches requirements.
@@ -35,6 +36,7 @@ type Config struct {
 	AuthorizedApp authorizedapp.Config
 	Database      database.Config
 	SecretManager secrets.Config
+	Verification  verification.Config
 
 	Port               string        `env:"PORT, default=8080"`
 	MinRequestDuration time.Duration `env:"TARGET_REQUEST_DURATION, default=5s"`
diff --git a/internal/publish/model/exposure_model.go b/internal/publish/model/exposure_model.go
index 6c51b94..607259d 100644
--- a/internal/publish/model/exposure_model.go
+++ b/internal/publish/model/exposure_model.go
@@ -72,15 +72,15 @@ func ApplyTransmissionRiskOverrides(p *verifyapi.Publish, overrides verifyapi.Tr
 // from direct access.
 // Mark records as writable/nowritable - is exposure key encrypted.
 type Exposure struct {
-	ExposureKey      []byte    `db:"exposure_key"`
-	TransmissionRisk int       `db:"transmission_risk"`
-	AppPackageName   string    `db:"app_package_name"`
-	Regions          []string  `db:"regions"`
-	IntervalNumber   int32     `db:"interval_number"`
-	IntervalCount    int32     `db:"interval_count"`
-	CreatedAt        time.Time `db:"created_at"`
-	LocalProvenance  bool      `db:"local_provenance"`
-	FederationSyncID int64     `db:"sync_id"`
+	ExposureKey      []byte
+	TransmissionRisk int
+	AppPackageName   string
+	Regions          []string
+	IntervalNumber   int32
+	IntervalCount    int32
+	CreatedAt        time.Time
+	LocalProvenance  bool
+	FederationSyncID int64
 }
 
 // IntervalNumber calculates the exposure notification system interval
diff --git a/internal/publish/publish.go b/internal/publish/publish.go
index ab855cf..ab585ff 100644
--- a/internal/publish/publish.go
+++ b/internal/publish/publish.go
@@ -54,13 +54,18 @@ func NewHandler(ctx context.Context, config *Config, env *serverenv.ServerEnv) (
 	logger.Infof("max interval start age: %v", config.MaxIntervalAge)
 	logger.Infof("truncate window: %v", config.TruncateWindow)
 
+	verifier, err := verification.New(verifydb.New(env.Database()), &config.Verification)
+	if err != nil {
+		return nil, fmt.Errorf("verification.New: %w", err)
+	}
+
 	return &publishHandler{
 		serverenv:             env,
 		transformer:           transformer,
 		config:                config,
 		database:              database.New(env.Database()),
 		authorizedAppProvider: env.AuthorizedAppProvider(),
-		verifier:              verification.New(verifydb.New(env.Database())),
+		verifier:              verifier,
 	}, nil
 }
 
diff --git a/internal/secrets/cacher.go b/internal/secrets/cacher.go
index b5d18f8..80f21c4 100644
--- a/internal/secrets/cacher.go
+++ b/internal/secrets/cacher.go
@@ -17,10 +17,9 @@ package secrets
 import (
 	"context"
 	"fmt"
-	"sync"
 	"time"
 
-	"github.com/google/exposure-notifications-server/internal/logging"
+	"github.com/google/exposure-notifications-server/internal/cache"
 )
 
 // Compile-time check to verify implements interface.
@@ -29,11 +28,8 @@ var _ SecretManager = (*Cacher)(nil)
 // Cacher is a secret manager implementation that wraps another secret manager
 // and caches secret values.
 type Cacher struct {
-	sm  SecretManager
-	ttl time.Duration
-
-	cache      map[string]*cachedItem
-	cacheMutex sync.Mutex
+	sm    SecretManager
+	cache *cache.Cache
 }
 
 type cachedItem struct {
@@ -48,44 +44,38 @@ func NewCacher(ctx context.Context, f SecretManagerFunc, ttl time.Duration) (Sec
 		return nil, fmt.Errorf("cacher: %w", err)
 	}
 
-	return WrapCacher(ctx, sm, ttl), nil
+	return WrapCacher(ctx, sm, ttl)
 }
 
 // WrapCacher wraps an existing SecretManager with caching.
-func WrapCacher(ctx context.Context, sm SecretManager, ttl time.Duration) SecretManager {
+func WrapCacher(ctx context.Context, sm SecretManager, ttl time.Duration) (SecretManager, error) {
+	cache, err := cache.New(ttl)
+	if err != nil {
+		return nil, err
+	}
 	return &Cacher{
 		sm:    sm,
-		ttl:   ttl,
-		cache: make(map[string]*cachedItem),
-	}
+		cache: cache,
+	}, nil
 }
 
 // GetSecretValue implements the SecretManager interface, but caches values and
 // retrieves them from the cache.
 func (sm *Cacher) GetSecretValue(ctx context.Context, name string) (string, error) {
-	logger := logging.FromContext(ctx)
-
-	// Lock
-	sm.cacheMutex.Lock()
-	defer sm.cacheMutex.Unlock()
-
-	// Lookup in cache
-	if i, ok := sm.cache[name]; ok && time.Since(i.cachedAt) < sm.ttl {
-		logger.Debugf("loaded secret %v from cache", name)
-		return i.value, nil
+	lookup := func() (interface{}, error) {
+		// Delegate lookup to parent sm.
+		plaintext, err := sm.sm.GetSecretValue(ctx, name)
+		if err != nil {
+			return "", err
+		}
+		return plaintext, nil
 	}
 
-	// Delegate lookup to parent sm.
-	plaintext, err := sm.sm.GetSecretValue(ctx, name)
+	cacheVal, err := sm.cache.WriteThruLookup(name, lookup)
 	if err != nil {
-		return "", err
-	}
-
-	// Cache value
-	sm.cache[name] = &cachedItem{
-		value:    plaintext,
-		cachedAt: time.Now(),
+		return "", nil
 	}
 
-	return plaintext, nil
+	plaintext := cacheVal.(string)
+	return plaintext, err
 }
diff --git a/internal/server/server.go b/internal/server/server.go
index c79b03f..f19bf8e 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -21,6 +21,7 @@ import (
 	"fmt"
 	"net"
 	"net/http"
+	"strconv"
 	"time"
 
 	"github.com/google/exposure-notifications-server/internal/logging"
@@ -31,11 +32,14 @@ import (
 // Server provides a gracefully-stoppable http server implementation. It is safe
 // for concurrent use in goroutines.
 type Server struct {
+	ip       string
+	port     string
 	listener net.Listener
 }
 
 // New creates a new server listening on the provided address that responds to
-// the http.Handler. It starts the listener, but does not start the server.
+// the http.Handler. It starts the listener, but does not start the server. If
+// an empty port is given, the server randombly chooses one.
 func New(port string) (*Server, error) {
 	// Create the net listener first, so the connection ready when we return. This
 	// guarantees that it can accept requests.
@@ -46,6 +50,8 @@ func New(port string) (*Server, error) {
 	}
 
 	return &Server{
+		ip:       listener.Addr().(*net.TCPAddr).IP.String(),
+		port:     strconv.Itoa(listener.Addr().(*net.TCPAddr).Port),
 		listener: listener,
 	}, nil
 }
@@ -138,3 +144,18 @@ func (s *Server) ServeGRPC(ctx context.Context, srv *grpc.Server) error {
 		return nil
 	}
 }
+
+// Addr returns the server's listening address (ip + port).
+func (s *Server) Addr() string {
+	return net.JoinHostPort(s.ip, s.port)
+}
+
+// IP returns the server's listening IP.
+func (s *Server) IP() string {
+	return s.ip
+}
+
+// Port returns the server's listening port.
+func (s *Server) Port() string {
+	return s.port
+}
diff --git a/internal/setup/setup.go b/internal/setup/setup.go
index 7d1826a..5bb36b9 100644
--- a/internal/setup/setup.go
+++ b/internal/setup/setup.go
@@ -85,6 +85,8 @@ func SetupWith(ctx context.Context, config interface{}, l envconfig.Lookuper) (*
 	// processors may require access to secrets.
 	var sm secrets.SecretManager
 	if provider, ok := config.(SecretManagerConfigProvider); ok {
+		logger.Info("configuring secret manager")
+
 		// 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
@@ -106,7 +108,10 @@ func SetupWith(ctx context.Context, config interface{}, l envconfig.Lookuper) (*
 
 		// Enable caching, if a TTL was provided.
 		if ttl := smConfig.SecretCacheTTL; ttl > 0 {
-			sm = secrets.WrapCacher(ctx, sm, ttl)
+			sm, err = secrets.WrapCacher(ctx, sm, ttl)
+			if err != nil {
+				return nil, fmt.Errorf("unable to create secret manager cache: %w", err)
+			}
 		}
 
 		// Update the mutators to process secrets.
@@ -114,11 +119,15 @@ func SetupWith(ctx context.Context, config interface{}, l envconfig.Lookuper) (*
 
 		// Update serverEnv setup.
 		serverEnvOpts = append(serverEnvOpts, serverenv.WithSecretManager(sm))
+
+		logger.Infow("secret manager", "config", smConfig)
 	}
 
 	// Load the key manager.
 	var km signing.KeyManager
 	if provider, ok := config.(KeyManagerConfigProvider); ok {
+		logger.Info("configuring key manager")
+
 		kmConfig := provider.KeyManagerConfig()
 		if err := envconfig.ProcessWith(ctx, kmConfig, l, mutatorFuncs...); err != nil {
 			return nil, fmt.Errorf("unable to process key manager env: %w", err)
@@ -132,16 +141,20 @@ func SetupWith(ctx context.Context, config interface{}, l envconfig.Lookuper) (*
 
 		// Update serverEnv setup.
 		serverEnvOpts = append(serverEnvOpts, serverenv.WithKeyManager(km))
+
+		logger.Infow("key manager", "config", kmConfig)
 	}
 
 	// Process first round of environment variables.
 	if err := envconfig.ProcessWith(ctx, config, l, mutatorFuncs...); err != nil {
 		return nil, fmt.Errorf("error loading environment variables: %w", err)
 	}
-	logger.Infof("Effective environment variables: %+v", config)
+	logger.Infow("provided", "config", config)
 
 	// Configure blob storage.
 	if provider, ok := config.(BlobstoreConfigProvider); ok {
+		logger.Info("configuring blobstore")
+
 		bsConfig := provider.BlobstoreConfig()
 		blobStore, err := storage.BlobstoreFor(ctx, bsConfig.BlobstoreType)
 		if err != nil {
@@ -151,27 +164,29 @@ func SetupWith(ctx context.Context, config interface{}, l envconfig.Lookuper) (*
 
 		// Update serverEnv setup.
 		serverEnvOpts = append(serverEnvOpts, blobStorage)
+
+		logger.Infow("blobstore", "config", bsConfig)
 	}
 
 	// Setup the database connection.
 	if provider, ok := config.(DatabaseConfigProvider); ok {
+		logger.Info("configuring database")
+
 		dbConfig := provider.DatabaseConfig()
 		db, err := database.NewFromEnv(ctx, dbConfig)
 		if err != nil {
 			return nil, fmt.Errorf("unable to connect to database: %v", err)
 		}
-		{
-			// Log the database config, but omit the password field.
-			redactedDB := dbConfig
-			redactedDB.Password = "<hidden>"
-			logger.Infof("Effective DB config: %+v", redactedDB)
-		}
 
 		// Update serverEnv setup.
 		serverEnvOpts = append(serverEnvOpts, serverenv.WithDatabase(db))
 
+		logger.Infow("database", "config", dbConfig)
+
 		// AuthorizedApp must come after database setup due to the dependency.
 		if provider, ok := config.(AuthorizedAppConfigProvider); ok {
+			logger.Info("configuring authorizedapp")
+
 			aaConfig := provider.AuthorizedAppConfig()
 			aa, err := authorizedapp.NewDatabaseProvider(ctx, db, aaConfig, authorizedapp.WithSecretManager(sm))
 			if err != nil {
@@ -182,6 +197,8 @@ func SetupWith(ctx context.Context, config interface{}, l envconfig.Lookuper) (*
 
 			// Update serverEnv setup.
 			serverEnvOpts = append(serverEnvOpts, serverenv.WithAuthorizedAppProvider(aa))
+
+			logger.Infow("authorizedapp", "config", aaConfig)
 		}
 	}
 
diff --git a/internal/signing/config.go b/internal/signing/config.go
index c87ac20..ab644dd 100644
--- a/internal/signing/config.go
+++ b/internal/signing/config.go
@@ -19,6 +19,7 @@ type KeyManagerType string
 
 const (
 	KeyManagerTypeAWSKMS         KeyManagerType = "AWS_KMS"
+	KeyManagerTypeAzureKeyVault  KeyManagerType = "AZURE_KEY_VAULT"
 	KeyManagerTypeGoogleCloudKMS KeyManagerType = "GOOGLE_CLOUD_KMS"
 	KeyManagerTypeHashiCorpVault KeyManagerType = "HASHICORP_VAULT"
 	KeyManagerTypeNoop           KeyManagerType = "NOOP"
diff --git a/internal/signing/signing.go b/internal/signing/signing.go
index 467c278..e820bb7 100644
--- a/internal/signing/signing.go
+++ b/internal/signing/signing.go
@@ -33,6 +33,8 @@ func KeyManagerFor(ctx context.Context, typ KeyManagerType) (KeyManager, error)
 	switch typ {
 	case KeyManagerTypeAWSKMS:
 		return NewAWSKMS(ctx)
+	case KeyManagerTypeAzureKeyVault:
+		return NewAzureKeyVault(ctx)
 	case KeyManagerTypeGoogleCloudKMS:
 		return NewGoogleCloudKMS(ctx)
 	case KeyManagerTypeHashiCorpVault:
diff --git a/internal/storage/aws_s3.go b/internal/storage/aws_s3.go
index fed03da..ee5f4d2 100644
--- a/internal/storage/aws_s3.go
+++ b/internal/storage/aws_s3.go
@@ -18,8 +18,10 @@ import (
 	"bytes"
 	"context"
 	"fmt"
+	"io/ioutil"
 
 	"github.com/aws/aws-sdk-go/aws"
+	"github.com/aws/aws-sdk-go/aws/awserr"
 	"github.com/aws/aws-sdk-go/aws/session"
 	"github.com/aws/aws-sdk-go/service/s3"
 )
@@ -77,3 +79,27 @@ func (s *AWSS3) DeleteObject(ctx context.Context, bucket, key string) error {
 	}
 	return nil
 }
+
+// GetObject returns the contents for the given object. If the object does not
+// exist, it returns ErrNotFound.
+func (s *AWSS3) GetObject(ctx context.Context, bucket, key string) ([]byte, error) {
+	o, err := s.svc.GetObjectWithContext(ctx, &s3.GetObjectInput{
+		Bucket: aws.String(bucket),
+		Key:    aws.String(key),
+	})
+	if err != nil {
+		if aerr, ok := err.(awserr.Error); ok &&
+			aerr.Code() == s3.ErrCodeNoSuchBucket || aerr.Code() == s3.ErrCodeNoSuchKey {
+			return nil, ErrNotFound
+		}
+		return nil, fmt.Errorf("failed to get object: %w", err)
+	}
+	defer o.Body.Close()
+
+	b, err := ioutil.ReadAll(o.Body)
+	if err != nil {
+		return nil, fmt.Errorf("failed to read object: %w", err)
+	}
+
+	return b, nil
+}
diff --git a/internal/storage/azure_blobstore.go b/internal/storage/azure_blobstore.go
index ae3ffa5..02fa613 100644
--- a/internal/storage/azure_blobstore.go
+++ b/internal/storage/azure_blobstore.go
@@ -15,8 +15,10 @@
 package storage
 
 import (
+	"bytes"
 	"context"
 	"fmt"
+	"io"
 	"net/url"
 	"os"
 
@@ -95,3 +97,23 @@ func (s *AzureBlobstore) DeleteObject(ctx context.Context, container, name strin
 	}
 	return nil
 }
+
+// GetObject returns the contents for the given object. If the object does not
+// exist, it returns ErrNotFound.
+func (s *AzureBlobstore) GetObject(ctx context.Context, container, name string) ([]byte, error) {
+	blobURL := s.serviceURL.NewContainerURL(container).NewBlockBlobURL(name)
+	dr, err := blobURL.Download(ctx, 0, azblob.CountToEnd, azblob.BlobAccessConditions{}, false)
+	if err != nil {
+		return nil, fmt.Errorf("failed to download object: %w", err)
+	}
+
+	body := dr.Body(azblob.RetryReaderOptions{MaxRetryRequests: 5})
+	defer body.Close()
+
+	var b bytes.Buffer
+	if _, err := io.Copy(&b, body); err != nil {
+		return nil, fmt.Errorf("failed to read object: %w", err)
+	}
+
+	return b.Bytes(), nil
+}
diff --git a/internal/storage/config.go b/internal/storage/config.go
index 8407b2b..f1e3955 100644
--- a/internal/storage/config.go
+++ b/internal/storage/config.go
@@ -20,8 +20,9 @@ type BlobstoreType string
 const (
 	BlobstoreTypeAWSS3              BlobstoreType = "AWS_S3"
 	BlobstoreTypeAzureBlobStorage   BlobstoreType = "AZURE_BLOB_STORAGE"
-	BlobstoreTypeGoogleCloudStorage BlobstoreType = "GOOGLE_CLOUD_STORAGE"
 	BlobstoreTypeFilesystem         BlobstoreType = "FILESYSTEM"
+	BlobstoreTypeGoogleCloudStorage BlobstoreType = "GOOGLE_CLOUD_STORAGE"
+	BlobstoreTypeMemory             BlobstoreType = "MEMORY"
 	BlobstoreTypeNoop               BlobstoreType = "NOOP"
 )
 
diff --git a/internal/storage/filesystem_storage.go b/internal/storage/filesystem_storage.go
index 9088bab..e4f9721 100644
--- a/internal/storage/filesystem_storage.go
+++ b/internal/storage/filesystem_storage.go
@@ -55,3 +55,17 @@ func (s *FilesystemStorage) DeleteObject(ctx context.Context, folder, filename s
 	}
 	return nil
 }
+
+// GetObject returns the contents for the given object. If the object does not
+// exist, it returns ErrNotFound.
+func (s *FilesystemStorage) GetObject(ctx context.Context, folder, filename string) ([]byte, error) {
+	pth := filepath.Join(folder, filename)
+	b, err := ioutil.ReadFile(pth)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return nil, ErrNotFound
+		}
+		return nil, fmt.Errorf("failed to read file: %w", err)
+	}
+	return b, nil
+}
diff --git a/internal/storage/google_cloud_storage.go b/internal/storage/google_cloud_storage.go
index 009c055..20e16e6 100644
--- a/internal/storage/google_cloud_storage.go
+++ b/internal/storage/google_cloud_storage.go
@@ -15,9 +15,11 @@
 package storage
 
 import (
+	"bytes"
 	"context"
 	"errors"
 	"fmt"
+	"io"
 
 	"cloud.google.com/go/storage"
 )
@@ -42,13 +44,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, cacheable bool) error {
+func (s *GoogleCloudStorage) CreateObject(ctx context.Context, bucket, objectName string, contents []byte, cacheable bool) error {
 	cacheControl := "public, max-age=86400"
 	if !cacheable {
 		cacheControl = "no-cache, max-age=0"
 	}
 
-	wc := gcs.client.Bucket(bucket).Object(objectName).NewWriter(ctx)
+	wc := s.client.Bucket(bucket).Object(objectName).NewWriter(ctx)
 	wc.Metadata = map[string]string{
 		"Cache-Control": cacheControl,
 	}
@@ -63,8 +65,8 @@ func (gcs *GoogleCloudStorage) CreateObject(ctx context.Context, bucket, objectN
 
 // DeleteObject deletes a cloud storage object, returns nil if the object was
 // successfully deleted, or of the object doesn't exist.
-func (gcs *GoogleCloudStorage) DeleteObject(ctx context.Context, bucket, objectName string) error {
-	if err := gcs.client.Bucket(bucket).Object(objectName).Delete(ctx); err != nil {
+func (s *GoogleCloudStorage) DeleteObject(ctx context.Context, bucket, objectName string) error {
+	if err := s.client.Bucket(bucket).Object(objectName).Delete(ctx); err != nil {
 		if errors.Is(err, storage.ErrObjectNotExist) {
 			// Object doesn't exist; presumably already deleted.
 			return nil
@@ -73,3 +75,22 @@ func (gcs *GoogleCloudStorage) DeleteObject(ctx context.Context, bucket, objectN
 	}
 	return nil
 }
+
+// GetObject returns the contents for the given object. If the object does not
+// exist, it returns ErrNotFound.
+func (s *GoogleCloudStorage) GetObject(ctx context.Context, bucket, object string) ([]byte, error) {
+	r, err := s.client.Bucket(bucket).Object(object).NewReader(ctx)
+	if err != nil {
+		if errors.Is(err, storage.ErrObjectNotExist) {
+			return nil, ErrNotFound
+		}
+	}
+	defer r.Close()
+
+	var b bytes.Buffer
+	if _, err := io.Copy(&b, r); err != nil {
+		return nil, fmt.Errorf("failed to download bytes: %w", err)
+	}
+
+	return b.Bytes(), nil
+}
diff --git a/internal/storage/memory.go b/internal/storage/memory.go
new file mode 100644
index 0000000..83ea547
--- /dev/null
+++ b/internal/storage/memory.go
@@ -0,0 +1,73 @@
+// 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"
+	"path"
+	"sync"
+)
+
+// Compile-time check to verify implements interface.
+var _ Blobstore = (*Memory)(nil)
+
+// Memory implements Blobstore and provides the ability write files to
+// memory.
+type Memory struct {
+	lock sync.Mutex
+	data map[string][]byte
+}
+
+// NewMemory creates a Blobstore that writes data in memory.
+func NewMemory(_ context.Context) (Blobstore, error) {
+	return &Memory{
+		data: make(map[string][]byte),
+	}, nil
+}
+
+// CreateObject creates a new object.
+func (s *Memory) CreateObject(_ context.Context, folder, filename string, contents []byte, cacheable bool) error {
+	s.lock.Lock()
+	defer s.lock.Unlock()
+
+	pth := path.Join(folder, filename)
+	s.data[pth] = contents
+	return nil
+}
+
+// DeleteObject deletes an object. It returns nil if the object was deleted or
+// if the object no longer exists.
+func (s *Memory) DeleteObject(_ context.Context, folder, filename string) error {
+	s.lock.Lock()
+	defer s.lock.Unlock()
+
+	pth := path.Join(folder, filename)
+	delete(s.data, pth)
+	return nil
+}
+
+// GetObject returns the contents for the given object. If the object does not
+// exist, it returns ErrNotFound.
+func (s *Memory) GetObject(_ context.Context, folder, filename string) ([]byte, error) {
+	s.lock.Lock()
+	defer s.lock.Unlock()
+
+	pth := path.Join(folder, filename)
+	v, ok := s.data[pth]
+	if !ok {
+		return nil, ErrNotFound
+	}
+	return v, nil
+}
diff --git a/internal/storage/noop.go b/internal/storage/noop.go
index 4ca2e08..04c6e5e 100644
--- a/internal/storage/noop.go
+++ b/internal/storage/noop.go
@@ -26,10 +26,14 @@ func NewNoop(ctx context.Context) (Blobstore, error) {
 	return &Noop{}, nil
 }
 
-func (s *Noop) CreateObject(ctx context.Context, folder, filename string, contents []byte, cacheable bool) error {
+func (s *Noop) CreateObject(_ context.Context, _, _ string, _ []byte, _ bool) error {
 	return nil
 }
 
-func (s *Noop) DeleteObject(ctx context.Context, folder, filename string) error {
+func (s *Noop) DeleteObject(_ context.Context, _, _ string) error {
 	return nil
 }
+
+func (s *Noop) GetObject(_ context.Context, _, _ string) ([]byte, error) {
+	return nil, nil
+}
diff --git a/internal/storage/storage.go b/internal/storage/storage.go
index 4b1e07e..b12c7c6 100644
--- a/internal/storage/storage.go
+++ b/internal/storage/storage.go
@@ -20,13 +20,18 @@ import (
 	"fmt"
 )
 
+var ErrNotFound = fmt.Errorf("storage object not found")
+
 // 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, cacheable bool) error
+	CreateObject(ctx context.Context, parent, name string, contents []byte, cacheable bool) error
+
+	// DeleteObject deletes an object or does nothing if the object doesn't exist.
+	DeleteObject(ctx context.Context, parent, bame string) error
 
-	// DeleteObject deltes an object or does nothing if the object doesn't exist.
-	DeleteObject(ctx context.Context, bucket, objectName string) error
+	// GetObject fetches the object's contents.
+	GetObject(ctx context.Context, parent, name string) ([]byte, error)
 }
 
 // BlobstoreFor returns the blob store for the given type, or an error if one
@@ -41,6 +46,8 @@ func BlobstoreFor(ctx context.Context, typ BlobstoreType) (Blobstore, error) {
 		return NewGoogleCloudStorage(ctx)
 	case BlobstoreTypeFilesystem:
 		return NewFilesystemStorage(ctx)
+	case BlobstoreTypeMemory:
+		return NewMemory(ctx)
 	case BlobstoreTypeNoop:
 		return NewNoop(ctx)
 	default:
diff --git a/internal/verification/config.go b/internal/verification/config.go
new file mode 100644
index 0000000..3dba770
--- /dev/null
+++ b/internal/verification/config.go
@@ -0,0 +1,23 @@
+// 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 "time"
+
+// Config represents the available configuration for the public health authority
+// verification piece.
+type Config struct {
+	CacheDuration time.Duration `env:"VERIFICATION_CACHE_DURATION, default=5m"`
+}
diff --git a/internal/verification/phaverify.go b/internal/verification/phaverify.go
index e030333..3205be6 100644
--- a/internal/verification/phaverify.go
+++ b/internal/verification/phaverify.go
@@ -24,7 +24,9 @@ import (
 
 	aamodel "github.com/google/exposure-notifications-server/internal/authorizedapp/model"
 	"github.com/google/exposure-notifications-server/internal/base64util"
+	"github.com/google/exposure-notifications-server/internal/cache"
 	"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"
@@ -34,12 +36,17 @@ import (
 
 // Verifier can be used to verify public health authority diagnosis verification certificates.
 type Verifier struct {
-	db *database.HealthAuthorityDB
+	db      *database.HealthAuthorityDB
+	haCache *cache.Cache
 }
 
 // New creates a new verifier, based on this DB handle.
-func New(db *database.HealthAuthorityDB) *Verifier {
-	return &Verifier{db}
+func New(db *database.HealthAuthorityDB, config *Config) (*Verifier, error) {
+	cache, err := cache.New(config.CacheDuration)
+	if err != nil {
+		return nil, err
+	}
+	return &Verifier{db, cache}, nil
 }
 
 // VerifyDiagnosisCertificate accepts a publish request (from which is extracts the JWT),
@@ -62,12 +69,21 @@ func (v *Verifier) VerifyDiagnosisCertificate(ctx context.Context, authApp *aamo
 			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)
+		lookup := func() (interface{}, error) {
+			// 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)
+			}
+			return ha, nil
+		}
+		cacheVal, err := v.haCache.WriteThruLookup(claims.Issuer, lookup)
 		if err != nil {
-			return nil, fmt.Errorf("error looking up issuer: %v : %w", claims.Issuer, err)
+			return nil, err
 		}
 
+		ha := cacheVal.(*model.HealthAuthority)
+
 		// Find a key version.
 		for _, hak := range ha.Keys {
 			// Key version matches and the key is valid based on the current time.
diff --git a/migrations/000030_remove_package_and_bundle.down.sql b/migrations/000030_remove_package_and_bundle.down.sql
new file mode 100644
index 0000000..f8a2a2c
--- /dev/null
+++ b/migrations/000030_remove_package_and_bundle.down.sql
@@ -0,0 +1,21 @@
+-- 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 SignatureInfo
+  add column app_package_name VARCHAR(1000) NOT NULL DEFAULT '',
+  add column bundle_id VARCHAR(1000) NOT NULL DEFAULT '';
+
+END;
diff --git a/migrations/000030_remove_package_and_bundle.up.sql b/migrations/000030_remove_package_and_bundle.up.sql
new file mode 100644
index 0000000..de1f27f
--- /dev/null
+++ b/migrations/000030_remove_package_and_bundle.up.sql
@@ -0,0 +1,21 @@
+-- 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 SignatureInfo
+  drop column app_package_name,
+  drop column bundle_id;
+
+END;
diff --git a/scripts/build b/scripts/build
index c3ace30..e81dcac 100755
--- a/scripts/build
+++ b/scripts/build
@@ -19,6 +19,11 @@ set -eEuo pipefail
 ROOT="$(cd "$(dirname "$0")/.." &>/dev/null; pwd -P)"
 ALL_SERVICES="cleanup-export,cleanup-exposure,export,exposure,federationin,federationout,generate"
 
+if [ -n "$(git status --porcelain)" ]; then
+  echo "✋ Uncommitted local changes!" >&2
+  exit 1
+fi
+
 if [ -z "${PROJECT_ID:-}" ]; then
   echo "✋ Missing PROJECT_ID!" >&2
   exit 1
@@ -35,7 +40,7 @@ if [ "${SERVICES}" == "all" ]; then
 fi
 
 if [ -z "${TAG:-}" ]; then
-  TAG="$(date '+%Y%m%d%H%M%S')"
+  TAG="$(git rev-parse --short HEAD)"
   echo "🚧 Using generated tag! (${TAG})" >&2
 fi
 
diff --git a/scripts/deploy b/scripts/deploy
index 240e4d6..1ef7b41 100755
--- a/scripts/deploy
+++ b/scripts/deploy
@@ -34,14 +34,14 @@ if [ -z "${SERVICES:-}" ]; then
   exit 1
 fi
 
-if [ "${SERVICES}" == "all" ]; then
-  SERVICES="${ALL_SERVICES}"
-  echo "🚧 Building all services! (${SERVICES})" >&2
+if [ -z "${TAG:-}" ]; then
+  echo "✋ Missing TAG!" >&2
+  exit 1
 fi
 
-if [ -z "${TAG:-}" ]; then
-  TAG="$(date '+%Y%m%d%H%M%S')"
-  echo "🚧 Using generated tag! (${TAG})" >&2
+if [ "${SERVICES}" == "all" ]; then
+  SERVICES="${ALL_SERVICES}"
+  echo "🚧 Deploying all services! (${SERVICES})" >&2
 fi
 
 IFS=',' read -ra SERVICES_ARR <<< "${SERVICES}"
diff --git a/terraform/README.md b/terraform/README.md
index 4bae42f..cb0c4c3 100644
--- a/terraform/README.md
+++ b/terraform/README.md
@@ -58,7 +58,7 @@ For full instructions on deploying, view the [deployment docs](../docs/deploying
     system.
 
     ```text
-    $ echo "create_generate_service = true" >> ./terraform.tfvars
+    $ echo 'generate_cron_schedule = "*/15 * * * *"' >> ./terraform.tfvars
     ```
 
 1.  (Optional, but recommended) Create a Cloud Storage bucket for storing remote
diff --git a/tools/admin-console/templates/siginfo.html b/tools/admin-console/templates/siginfo.html
index 19ae8a3..6585ac9 100644
--- a/tools/admin-console/templates/siginfo.html
+++ b/tools/admin-console/templates/siginfo.html
@@ -65,24 +65,6 @@ by Apple and Google).</p>
     </div>
   </div>
 
-  <div class="form-group row">
-    <label class="control-label col-sm-3" for="AppPackageName">App Package Name:</label>
-    <div class="col-sm-6">
-      <input type="text" id="AppPackageName" name="AppPackageName" size="50" value="{{.siginfo.AppPackageName}}">
-      <small id="AppPackageNameHelpBlock" class="form-text text-muted">The android App Package Name
-      to put in the SignatureInfo block in exports. <strong>Normally left blank</strong></small>
-    </div>
-  </div>
-
-  <div class="form-group row">
-    <label class="control-label col-sm-3" for="BundleID">BundleID:</label>
-    <div class="col-sm-6">
-      <input type="text" id="BundleID" name="BundleID" size="50" value="{{.siginfo.BundleID}}">
-      <small id="BundleIDHelpBlock" class="form-text text-muted">The iOS BundleID to
-      put in the SignatureInfo block in exports.</small>
-    </div>
-  </div>
-
   <hr/>
   <button type="submit" class="btn btn-outline-primary" name="TODO" value="save">Save Changes</button>
   <a href="/" class="btn btn-outline-secondary">Cancel</a>
diff --git a/tools/export-config/main.go b/tools/export-config/main.go
index f590391..88fce33 100644
--- a/tools/export-config/main.go
+++ b/tools/export-config/main.go
@@ -38,8 +38,6 @@ var (
 	signingKey        = flag.String("signing-key", "", "The KMS resource ID to use for signing batches.")
 	signingKeyID      = flag.String("signing-key-id", "", "The ID of the signing key (for clients).")
 	signingKeyVersion = flag.String("signing-key-version", "", "The version of the signing key (for clients).")
-	appPkgID          = flag.String("app-pkg-id", "", "The App Package ID to put in export headers")
-	bundleID          = flag.String("bundle-id", "", "The BundleID to put in export headers")
 )
 
 func main() {
@@ -90,8 +88,6 @@ func main() {
 
 	si := model.SignatureInfo{
 		SigningKey:        *signingKey,
-		AppPackageName:    *appPkgID,
-		BundleID:          *bundleID,
 		SigningKeyVersion: *signingKeyVersion,
 		SigningKeyID:      *signingKeyID,
 	}
diff --git a/tools/monolith/main.go b/tools/monolith/main.go
deleted file mode 100644
index 191e2b4..0000000
--- a/tools/monolith/main.go
+++ /dev/null
@@ -1,35 +0,0 @@
-// 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 main runs all the server components at different URL paths.
-package main
-
-import (
-	"github.com/google/exposure-notifications-server/internal/interrupt"
-	"github.com/google/exposure-notifications-server/internal/logging"
-	"github.com/google/exposure-notifications-server/internal/monolith"
-
-	// Enable observability with distributed tracing and metrics.
-	_ "github.com/google/exposure-notifications-server/internal/observability"
-)
-
-func main() {
-	ctx, done := interrupt.Context()
-	defer done()
-
-	if err := monolith.RunServer(ctx); err != nil {
-		logger := logging.FromContext(ctx)
-		logger.Fatal(err)
-	}
-}


Test patch:
diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go
index 9949e0f..073fe8e 100644
--- a/internal/cache/cache_test.go
+++ b/internal/cache/cache_test.go
@@ -73,7 +73,7 @@ func TestCache(t *testing.T) {
 	if got, hit := cache.Lookup("foo"); got != nil || hit {
 		t.Fatalf("expected key to expire, but still available")
 	}
-	// potential race, yeild CPU so that the purge go routine has a chance to run.
+	// potential race, yield CPU so that the purge go routine has a chance to run.
 	time.Sleep(duration)
 	checkSize(t, cache, 0)
 }
@@ -145,17 +145,25 @@ func TestConcurrentReaders(t *testing.T) {
 	lookupCount := 0
 	want := &order{12, 34}
 	lookerUpper := func() (interface{}, error) {
+		// The sleep here, reliably triggers a race condition on multiple entrants attempting
+		// to lookup the cache miss to primary storage. Only one will win!
+		time.Sleep(250 * time.Millisecond)
 		lookupCount++
 		return want, nil
 	}
 
-	done := make(chan bool, 10)
-	for i := 0; i < 10; i++ {
+	parallel := 10
+	done := make(chan bool, parallel)
+	for i := 0; i < parallel; i++ {
 		ver := i
 		go func() {
-			got, err := cache.WriteThruLookup("foo", lookerUpper)
+			gotCache, err := cache.WriteThruLookup("foo", lookerUpper)
 			if err != nil {
-				t.Errorf("routine: %v got unexpected error: %v", ver, err)
+				t.Fatalf("routine: %v got unexpected error: %v", ver, err)
+			}
+			got, ok := gotCache.(*order)
+			if !ok {
+				t.Fatalf("cache item of wrong type")
 			}
 			if diff := cmp.Diff(want, got); diff != "" {
 				t.Fatalf("routine: %v mismatch (-want, +got):\n%s", ver, diff)
@@ -164,11 +172,11 @@ func TestConcurrentReaders(t *testing.T) {
 		}()
 	}
 
-	for i := 0; i < 10; i++ {
+	for i := 0; i < parallel; i++ {
 		select {
 		case <-done:
 			continue
-		case <-time.After(1 * time.Second):
+		case <-time.After(2 * time.Second):
 			t.Fatal("gorountines didn't termine fast enough")
 		}
 	}
diff --git a/internal/integration/integration_test.go b/internal/integration/integration_test.go
new file mode 100644
index 0000000..d54997f
--- /dev/null
+++ b/internal/integration/integration_test.go
@@ -0,0 +1,194 @@
+// 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 integration
+
+import (
+	"context"
+	"net/http"
+	"testing"
+	"time"
+
+	"github.com/google/exposure-notifications-server/internal/authorizedapp"
+	"github.com/google/exposure-notifications-server/internal/cleanup"
+	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/export"
+	"github.com/google/exposure-notifications-server/internal/federationin"
+	"github.com/google/exposure-notifications-server/internal/publish"
+	"github.com/google/exposure-notifications-server/internal/secrets"
+	"github.com/google/exposure-notifications-server/internal/server"
+	"github.com/google/exposure-notifications-server/internal/serverenv"
+	"github.com/google/exposure-notifications-server/internal/signing"
+	"github.com/google/exposure-notifications-server/internal/storage"
+)
+
+func testServer(tb testing.TB) (*serverenv.ServerEnv, *http.Client) {
+	tb.Helper()
+
+	ctx := context.Background()
+
+	aa, err := authorizedapp.NewMemoryProvider(ctx, nil)
+	if err != nil {
+		tb.Fatal(err)
+	}
+
+	bs, err := storage.NewMemory(ctx)
+	if err != nil {
+		tb.Fatal(err)
+	}
+
+	db := database.NewTestDatabase(tb)
+
+	km, err := signing.NewNoop(ctx)
+	if err != nil {
+		tb.Fatal(err)
+	}
+
+	sm, err := secrets.NewNoop(ctx)
+	if err != nil {
+		tb.Fatal(err)
+	}
+
+	env := serverenv.New(ctx,
+		serverenv.WithAuthorizedAppProvider(aa),
+		serverenv.WithBlobStorage(bs),
+		serverenv.WithDatabase(db),
+		serverenv.WithKeyManager(km),
+		serverenv.WithSecretManager(sm),
+	)
+	// Note: don't call env.Cleanup() because the database helper closes the
+	// connection for us.
+
+	mux := http.NewServeMux()
+
+	// Cleanup export
+	cleanupExportConfig := &cleanup.Config{
+		Timeout: 10 * time.Minute,
+		TTL:     336 * time.Hour,
+	}
+
+	cleanupExportHandler, err := cleanup.NewExportHandler(cleanupExportConfig, env)
+	if err != nil {
+		tb.Fatal(err)
+	}
+	mux.Handle("/cleanup-export", cleanupExportHandler)
+
+	// Cleanup exposure
+	cleanupExposureConfig := &cleanup.Config{
+		Timeout: 10 * time.Minute,
+		TTL:     336 * time.Hour,
+	}
+
+	cleanupExposureHandler, err := cleanup.NewExposureHandler(cleanupExposureConfig, env)
+	if err != nil {
+		tb.Fatal(err)
+	}
+	mux.Handle("/cleanup-exposure", cleanupExposureHandler)
+
+	// Export
+	exportConfig := &export.Config{
+		CreateTimeout:  10 * time.Second,
+		WorkerTimeout:  10 * time.Second,
+		MinRecords:     1,
+		PaddingRange:   1,
+		MaxRecords:     10000,
+		TruncateWindow: 1 * time.Second,
+		MinWindowAge:   1 * time.Second,
+		TTL:            336 * time.Hour,
+	}
+
+	exportServer, err := export.NewServer(exportConfig, env)
+	if err != nil {
+		tb.Fatal(err)
+	}
+	mux.HandleFunc("/export/create-batches", exportServer.CreateBatchesHandler)
+	mux.HandleFunc("/export/do-work", exportServer.WorkerHandler)
+
+	// Federation
+	federationInConfig := &federationin.Config{
+		Timeout:        10 * time.Minute,
+		TruncateWindow: 1 * time.Hour,
+	}
+
+	mux.Handle("/federation-in", federationin.NewHandler(env, federationInConfig))
+
+	// Federation out
+	// TODO: this is a grpc listener and requires a lot of setup.
+
+	// Publish
+	publishConfig := &publish.Config{
+		MinRequestDuration:      50 * time.Millisecond,
+		MaxKeysOnPublish:        15,
+		MaxIntervalAge:          360 * time.Hour,
+		TruncateWindow:          1 * time.Hour,
+		DebugAPIResponses:       true,
+		DebugReleaseSameDayKeys: true,
+	}
+
+	publishHandler, err := publish.NewHandler(ctx, publishConfig, env)
+	if err != nil {
+		tb.Fatal(err)
+	}
+	mux.Handle("/publish", publishHandler)
+
+	srv, err := server.New("")
+	if err != nil {
+		tb.Fatal(err)
+	}
+
+	// Stop the server on cleanup
+	stopCtx, stop := context.WithCancel(ctx)
+	tb.Cleanup(stop)
+
+	// Start the server
+	go func() {
+		if err := srv.ServeHTTPHandler(stopCtx, mux); err != nil {
+			tb.Error(err)
+		}
+	}()
+
+	// Create a client
+	client := testClient(tb, srv)
+
+	return env, client
+}
+
+type prefixRoundTripper struct {
+	addr string
+	rt   http.RoundTripper
+}
+
+func (p *prefixRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
+	u := r.URL
+	if u.Scheme == "" {
+		u.Scheme = "http"
+	}
+	if u.Host == "" {
+		u.Host = p.addr
+	}
+
+	return p.rt.RoundTrip(r)
+}
+
+func testClient(tb testing.TB, srv *server.Server) *http.Client {
+	prt := &prefixRoundTripper{
+		addr: srv.Addr(),
+		rt:   http.DefaultTransport,
+	}
+
+	return &http.Client{
+		Timeout:   5 * time.Second,
+		Transport: prt,
+	}
+}
diff --git a/internal/integration/publish_test.go b/internal/integration/publish_test.go
new file mode 100644
index 0000000..147dcd3
--- /dev/null
+++ b/internal/integration/publish_test.go
@@ -0,0 +1,144 @@
+// 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 integration
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"net/http"
+	"testing"
+	"time"
+
+	authorizedappmodel "github.com/google/exposure-notifications-server/internal/authorizedapp/model"
+	exportdatabase "github.com/google/exposure-notifications-server/internal/export/database"
+	exportmodel "github.com/google/exposure-notifications-server/internal/export/model"
+	publishdb "github.com/google/exposure-notifications-server/internal/publish/database"
+	publishmodel "github.com/google/exposure-notifications-server/internal/publish/model"
+	"github.com/google/exposure-notifications-server/internal/util"
+	verifyapi "github.com/google/exposure-notifications-server/pkg/api/v1alpha1"
+)
+
+func TestPublish(t *testing.T) {
+	t.Parallel()
+
+	ctx := context.Background()
+	env, client := testServer(t)
+	db := env.Database()
+
+	// Create an authorized app.
+	aa := env.AuthorizedAppProvider()
+	if err := aa.Add(ctx, &authorizedappmodel.AuthorizedApp{
+		AppPackageName: "com.example.app",
+		AllowedRegions: map[string]struct{}{
+			"US": {},
+		},
+		AllowedHealthAuthorityIDs: map[int64]struct{}{
+			12345: {},
+		},
+
+		// TODO: hook up verification, and disable
+		BypassHealthAuthorityVerification: true,
+	}); err != nil {
+		t.Fatal(err)
+	}
+
+	// Create a signature info.
+	si := &exportmodel.SignatureInfo{
+		SigningKey:        "signer",
+		SigningKeyVersion: "v1",
+		SigningKeyID:      "US",
+	}
+	if err := exportdatabase.New(db).AddSignatureInfo(ctx, si); err != nil {
+		t.Fatal(err)
+	}
+
+	// Create an export config.
+	ec := &exportmodel.ExportConfig{
+		BucketName:       "my-bucket",
+		Period:           1 * time.Second,
+		OutputRegion:     "US",
+		From:             time.Now().Add(-2 * time.Second),
+		Thru:             time.Now().Add(1 * time.Hour),
+		SignatureInfoIDs: []int64{si.ID},
+	}
+	if err := exportdatabase.New(db).AddExportConfig(ctx, ec); err != nil {
+		t.Fatal(err)
+	}
+
+	payload := &verifyapi.Publish{
+		Keys:           util.GenerateExposureKeys(3, -1, false),
+		Regions:        []string{"US"},
+		AppPackageName: "com.example.app",
+
+		// TODO: hook up verification
+		VerificationPayload: "TODO",
+	}
+
+	var body bytes.Buffer
+	if err := json.NewEncoder(&body).Encode(payload); err != nil {
+		t.Fatal(err)
+	}
+
+	resp, err := client.Post("/publish", "application/json", &body)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer resp.Body.Close()
+
+	// Ensure we get a successful response code.
+	if got, want := resp.StatusCode, http.StatusOK; got != want {
+		t.Errorf("expected %v to be %v", got, want)
+	}
+
+	// Look up the exposures in the database.
+	criteria := publishdb.IterateExposuresCriteria{
+		OnlyLocalProvenance: false,
+	}
+
+	var exposures []*publishmodel.Exposure
+	if _, err := publishdb.New(db).IterateExposures(ctx, criteria, func(m *publishmodel.Exposure) error {
+		exposures = append(exposures, m)
+		return nil
+	}); err != nil {
+		t.Fatal(err)
+	}
+
+	if got, want := len(exposures), 3; got != want {
+		t.Errorf("expected %v to be %v: %#v", got, want, exposures)
+	}
+
+	// Create an export.
+	resp, err = client.Get("/export/create-batches")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer resp.Body.Close()
+
+	resp, err = client.Get("/export/do-work")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer resp.Body.Close()
+
+	// TODO: verify export has the correct file
+	b, err := env.Blobstore().GetObject(ctx, "my-bucket", "index.txt")
+	if err != nil {
+		t.Fatal(err)
+	}
+	_ = b
+
+	// TODO: verify signature
+}
diff --git a/internal/secrets/cacher_test.go b/internal/secrets/cacher_test.go
index b9c7080..2b25062 100644
--- a/internal/secrets/cacher_test.go
+++ b/internal/secrets/cacher_test.go
@@ -34,7 +34,10 @@ func TestCacher_GetSecretValue(t *testing.T) {
 	ctx := context.Background()
 
 	sm := &testSecretManager{value: "first"}
-	cached := WrapCacher(ctx, sm, 250*time.Millisecond)
+	cached, err := WrapCacher(ctx, sm, 250*time.Millisecond)
+	if err != nil {
+		t.Fatal(err)
+	}
 
 	// 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
index 6b53bed..31f1eb8 100644
--- a/internal/setup/setup_test.go
+++ b/internal/setup/setup_test.go
@@ -16,6 +16,9 @@ package setup_test
 
 import (
 	"context"
+	"io/ioutil"
+	"os"
+	"strings"
 	"testing"
 	"time"
 
@@ -35,8 +38,9 @@ var _ setup.KeyManagerConfigProvider = (*testConfig)(nil)
 var _ setup.SecretManagerConfigProvider = (*testConfig)(nil)
 
 type testConfig struct {
-	Database *database.Config
-	Secret   string `env:"MY_SECRET"`
+	Database   *database.Config
+	Secret     string `env:"MY_SECRET"`
+	SecretFile string `env:"MY_SECRET_FILE"`
 }
 
 func (t *testConfig) AuthorizedAppConfig() *authorizedapp.Config {
@@ -72,16 +76,24 @@ func (t *testConfig) SecretManagerConfig() *secrets.Config {
 func TestSetupWith(t *testing.T) {
 	t.Parallel()
 
+	tmp, err := ioutil.TempDir("", "")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer os.RemoveAll(tmp)
+
 	lookuper := envconfig.MapLookuper(map[string]string{
-		"MY_SECRET": "secret://foo",
+		"SECRETS_DIR":    tmp,
+		"MY_SECRET":      "secret://foo",
+		"MY_SECRET_FILE": "secret://foo?target=file",
 	})
 
-	ctx := context.Background()
-	_, dbconfig := database.NewTestDatabaseWithConfig(t)
-
 	t.Run("default", func(t *testing.T) {
 		t.Parallel()
 
+		ctx := context.Background()
+		_, dbconfig := database.NewTestDatabaseWithConfig(t)
+
 		config := &testConfig{Database: dbconfig}
 		env, err := setup.SetupWith(ctx, config, lookuper)
 		if err != nil {
@@ -97,6 +109,9 @@ func TestSetupWith(t *testing.T) {
 	t.Run("database", func(t *testing.T) {
 		t.Parallel()
 
+		ctx := context.Background()
+		_, dbconfig := database.NewTestDatabaseWithConfig(t)
+
 		config := &testConfig{Database: dbconfig}
 		env, err := setup.SetupWith(ctx, config, lookuper)
 		if err != nil {
@@ -113,6 +128,9 @@ func TestSetupWith(t *testing.T) {
 	t.Run("authorizedapp", func(t *testing.T) {
 		t.Parallel()
 
+		ctx := context.Background()
+		_, dbconfig := database.NewTestDatabaseWithConfig(t)
+
 		config := &testConfig{Database: dbconfig}
 		env, err := setup.SetupWith(ctx, config, lookuper)
 		if err != nil {
@@ -133,6 +151,9 @@ func TestSetupWith(t *testing.T) {
 	t.Run("blobstore", func(t *testing.T) {
 		t.Parallel()
 
+		ctx := context.Background()
+		_, dbconfig := database.NewTestDatabaseWithConfig(t)
+
 		config := &testConfig{Database: dbconfig}
 		env, err := setup.SetupWith(ctx, config, lookuper)
 		if err != nil {
@@ -153,6 +174,9 @@ func TestSetupWith(t *testing.T) {
 	t.Run("key_manager", func(t *testing.T) {
 		t.Parallel()
 
+		ctx := context.Background()
+		_, dbconfig := database.NewTestDatabaseWithConfig(t)
+
 		config := &testConfig{Database: dbconfig}
 		env, err := setup.SetupWith(ctx, config, lookuper)
 		if err != nil {
@@ -173,6 +197,9 @@ func TestSetupWith(t *testing.T) {
 	t.Run("secret_manager", func(t *testing.T) {
 		t.Parallel()
 
+		ctx := context.Background()
+		_, dbconfig := database.NewTestDatabaseWithConfig(t)
+
 		config := &testConfig{Database: dbconfig}
 		env, err := setup.SetupWith(ctx, config, lookuper)
 		if err != nil {
@@ -188,5 +215,13 @@ func TestSetupWith(t *testing.T) {
 		if _, ok := sm.(*secrets.Cacher); !ok {
 			t.Errorf("expected %T to be Cacher", sm)
 		}
+
+		if got, want := config.Secret, "noop-secret"; got != want {
+			t.Errorf("expected %v to be %v", got, want)
+		}
+
+		if got, want := config.SecretFile, tmp; !strings.Contains(got, want) {
+			t.Errorf("expected %v to contain %v", got, want)
+		}
 	})
 }
diff --git a/internal/storage/filesystem_storage_test.go b/internal/storage/filesystem_storage_test.go
index 0a9c129..a094c2b 100644
--- a/internal/storage/filesystem_storage_test.go
+++ b/internal/storage/filesystem_storage_test.go
@@ -24,6 +24,8 @@ import (
 )
 
 func TestFilesystemStorage_CreateObject(t *testing.T) {
+	t.Parallel()
+
 	tmp, err := ioutil.TempDir("", "")
 	if err != nil {
 		t.Fatal(err)
@@ -56,6 +58,8 @@ func TestFilesystemStorage_CreateObject(t *testing.T) {
 		tc := tc
 
 		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
 			ctx := context.Background()
 
 			storage, err := NewFilesystemStorage(ctx)
@@ -83,6 +87,8 @@ func TestFilesystemStorage_CreateObject(t *testing.T) {
 }
 
 func TestFilesystemStorage_DeleteObject(t *testing.T) {
+	t.Parallel()
+
 	f, err := ioutil.TempFile("", "")
 	if err != nil {
 		t.Fatal(err)
@@ -109,6 +115,8 @@ func TestFilesystemStorage_DeleteObject(t *testing.T) {
 		tc := tc
 
 		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
 			ctx := context.Background()
 
 			storage, err := NewFilesystemStorage(ctx)
@@ -122,3 +130,60 @@ func TestFilesystemStorage_DeleteObject(t *testing.T) {
 		})
 	}
 }
+
+func TestFilesystemStorage_GetObject(t *testing.T) {
+	t.Parallel()
+
+	f, err := ioutil.TempFile("", "")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, err := f.Write([]byte("hello")); err != nil {
+		t.Fatal(err)
+	}
+
+	cases := []struct {
+		name     string
+		folder   string
+		filepath string
+		contents []byte
+		err      bool
+	}{
+		{
+			name:     "default",
+			folder:   filepath.Dir(f.Name()),
+			filepath: filepath.Base(f.Name()),
+			contents: []byte("hello"),
+		},
+		{
+			name:     "not_exist",
+			folder:   filepath.Dir(f.Name()),
+			filepath: "not-exist",
+			err:      true,
+		},
+	}
+
+	for _, tc := range cases {
+		tc := tc
+
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			ctx := context.Background()
+
+			storage, err := NewFilesystemStorage(ctx)
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			b, err := storage.GetObject(ctx, tc.folder, tc.filepath)
+			if (err != nil) != tc.err {
+				t.Fatal(err)
+			}
+
+			if got, want := b, tc.contents; !bytes.Equal(got, want) {
+				t.Errorf("expected %v to be %v", got, want)
+			}
+		})
+	}
+}
diff --git a/internal/verification/phaverify_test.go b/internal/verification/phaverify_test.go
index 1a236a8..c0b58de 100644
--- a/internal/verification/phaverify_test.go
+++ b/internal/verification/phaverify_test.go
@@ -180,7 +180,10 @@ func TestVerifyCertificate(t *testing.T) {
 			publish.HMACKey = tc.MacKeyAdjustment + hmacKeyB64
 
 			// Actually test the verify code.
-			verifier := New(haDB)
+			verifier, err := New(haDB, &Config{time.Nanosecond})
+			if err != nil {
+				t.Fatal(err)
+			}
 			overrides, err := verifier.VerifyDiagnosisCertificate(ctx, authApp, &publish)
 			if err != nil {
 				if !strings.Contains(err.Error(), tc.Error) {