Repository: google/exposure-notifications-server
Pull request #314 (2020-05-16 03:41:39)

Add support for Azure KeyVault and HashiCorp Vault interfaces
This adds two new secret providers: KeyVault and HashiCorp Vault

It also abstracts the caching into another SecretManager which wraps any SecretManager implementation.

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

This pull-request has been approved by: *<a href="https://github.com/google/exposure-notifications-server/pull/314#" title="Author self-approved">sethvargo</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":[]} -->
sethvargo: /assign @mikehelmick 

Patch:
diff --git a/cmd/exposure/main.go b/cmd/exposure/main.go
index dc6c06a..68869f5 100644
--- a/cmd/exposure/main.go
+++ b/cmd/exposure/main.go
@@ -37,6 +37,15 @@ func main() {
 	}
 	defer closer()
 
+	if config.BypassDeviceCheck {
+		logger.Errorf("Bypassing DeviceCheck verification for iOS devices. " +
+			"This should only be done in test environments!")
+	}
+	if config.BypassSafetyNet {
+		logger.Errorf("Bypassing SafetyNet verification for Android devices. " +
+			"This should only be done in test environments!")
+	}
+
 	handler, err := publish.NewHandler(ctx, &config, env)
 	if err != nil {
 		logger.Fatalf("unable to create publish handler: %v", err)
diff --git a/cmd/monolith/main.go b/cmd/monolith/main.go
index 59e60d3..a1e0581 100644
--- a/cmd/monolith/main.go
+++ b/cmd/monolith/main.go
@@ -20,9 +20,9 @@ import (
 	"fmt"
 	"net/http"
 
+	"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/dbapiconfig"
 	"github.com/google/exposure-notifications-server/internal/export"
 	"github.com/google/exposure-notifications-server/internal/federationin"
 	"github.com/google/exposure-notifications-server/internal/handlers"
@@ -31,10 +31,15 @@ import (
 	"github.com/google/exposure-notifications-server/internal/setup"
 )
 
+var _ setup.DBConfigProvider = (*MonoConfig)(nil)
+var _ setup.AuthorizedAppConfigProvider = (*MonoConfig)(nil)
+var _ setup.BlobStorageConfigProvider = (*MonoConfig)(nil)
+var _ setup.KeyManagerProvider = (*MonoConfig)(nil)
+
 type MonoConfig struct {
 	Port string `envconfig:"PORT" default:"8080"`
 
-	APIConfigOpts *dbapiconfig.ConfigOpts
+	AuthorizedApp *authorizedapp.Config
 	Cleanup       *cleanup.Config
 	Export        *export.Config
 	Publish       *publish.Config
@@ -42,10 +47,10 @@ type MonoConfig struct {
 	FederationIn  *federationin.Config
 }
 
-func (c *MonoConfig) DB() *database.Config         { return c.Database }
-func (c *MonoConfig) KeyManager() bool             { return true }
-func (c *MonoConfig) BlobStorage() bool            { return true }
-func (c *MonoConfig) API() *dbapiconfig.ConfigOpts { return c.APIConfigOpts }
+func (c *MonoConfig) DB() *database.Config                       { return c.Database }
+func (c *MonoConfig) KeyManager() bool                           { return true }
+func (c *MonoConfig) BlobStorage() bool                          { return true }
+func (c *MonoConfig) AuthorizedAppConfig() *authorizedapp.Config { return c.AuthorizedApp }
 
 func main() {
 	ctx := context.Background()
diff --git a/go.mod b/go.mod
index 44715ba..7760f01 100644
--- a/go.mod
+++ b/go.mod
@@ -5,11 +5,17 @@ go 1.14
 require (
 	cloud.google.com/go v0.56.0
 	cloud.google.com/go/storage v1.6.0
+	github.com/Azure/azure-sdk-for-go v42.2.0+incompatible
+	github.com/Azure/go-autorest/autorest v0.10.1 // indirect
+	github.com/Azure/go-autorest/autorest/azure/auth v0.4.2 // indirect
+	github.com/Azure/go-autorest/autorest/to v0.3.0 // indirect
+	github.com/Azure/go-autorest/autorest/validation v0.2.0 // indirect
 	github.com/dgrijalva/jwt-go v3.2.0+incompatible
 	github.com/golang-migrate/migrate/v4 v4.10.0
 	github.com/golang/protobuf v1.4.0
 	github.com/google/go-cmp v0.4.0
 	github.com/google/uuid v1.1.1
+	github.com/hashicorp/vault/api v1.0.4
 	github.com/jackc/pgx/v4 v4.6.0
 	github.com/kelseyhightower/envconfig v1.4.0
 	github.com/kr/pretty v0.2.0 // indirect
diff --git a/go.sum b/go.sum
index 5f49c20..3ed74f5 100644
--- a/go.sum
+++ b/go.sum
@@ -29,8 +29,38 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo
 cloud.google.com/go/storage v1.6.0 h1:UDpwYIwla4jHGzZJaEJYx1tOejbgSoNqsAfHAUYe2r8=
 cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
 dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
+github.com/Azure/azure-sdk-for-go v42.2.0+incompatible h1:ezf8BQIvXYn+LSf+rDqOVyRG3bWkf/SXKYFz4zIBX1Q=
+github.com/Azure/azure-sdk-for-go v42.2.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
 github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 h1:w+iIsaOQNcT7OZ575w+acHgRric5iCyQh+xv+KJ4HB8=
 github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8=
+github.com/Azure/go-autorest/autorest v0.9.0/go.mod h1:xyHB1BMZT0cuDHU7I0+g046+BFDTQ8rEZB0s4Yfa6bI=
+github.com/Azure/go-autorest/autorest v0.9.3/go.mod h1:GsRuLYvwzLjjjRoWEIyMUaYq8GNUx2nRB378IPt/1p0=
+github.com/Azure/go-autorest/autorest v0.10.1 h1:uaB8A32IZU9YKs9v50+/LWIWTDHJk2vlGzbfd7FfESI=
+github.com/Azure/go-autorest/autorest v0.10.1/go.mod h1:/FALq9T/kS7b5J5qsQ+RSTUdAmGFqi0vUdVNNx8q630=
+github.com/Azure/go-autorest/autorest/adal v0.5.0/go.mod h1:8Z9fGy2MpX0PvDjB1pEgQTmVqjGhiHBW7RJJEciWzS0=
+github.com/Azure/go-autorest/autorest/adal v0.8.0/go.mod h1:Z6vX6WXXuyieHAXwMj0S6HY6e6wcHn37qQMBQlvY3lc=
+github.com/Azure/go-autorest/autorest/adal v0.8.1/go.mod h1:ZjhuQClTqx435SRJ2iMlOxPYt3d2C/T/7TiQCVZSn3Q=
+github.com/Azure/go-autorest/autorest/adal v0.8.2 h1:O1X4oexUxnZCaEUGsvMnr8ZGj8HI37tNezwY4npRqA0=
+github.com/Azure/go-autorest/autorest/adal v0.8.2/go.mod h1:ZjhuQClTqx435SRJ2iMlOxPYt3d2C/T/7TiQCVZSn3Q=
+github.com/Azure/go-autorest/autorest/azure/auth v0.4.2 h1:iM6UAvjR97ZIeR93qTcwpKNMpV+/FTWjwEbuPD495Tk=
+github.com/Azure/go-autorest/autorest/azure/auth v0.4.2/go.mod h1:90gmfKdlmKgfjUpnCEpOJzsUEjrWDSLwHIG73tSXddM=
+github.com/Azure/go-autorest/autorest/azure/cli v0.3.1 h1:LXl088ZQlP0SBppGFsRZonW6hSvwgL5gRByMbvUbx8U=
+github.com/Azure/go-autorest/autorest/azure/cli v0.3.1/go.mod h1:ZG5p860J94/0kI9mNJVoIoLgXcirM2gF5i2kWloofxw=
+github.com/Azure/go-autorest/autorest/date v0.1.0/go.mod h1:plvfp3oPSKwf2DNjlBjWF/7vwR+cUD/ELuzDCXwHUVA=
+github.com/Azure/go-autorest/autorest/date v0.2.0 h1:yW+Zlqf26583pE43KhfnhFcdmSWlm5Ew6bxipnr/tbM=
+github.com/Azure/go-autorest/autorest/date v0.2.0/go.mod h1:vcORJHLJEh643/Ioh9+vPmf1Ij9AEBM5FuBIXLmIy0g=
+github.com/Azure/go-autorest/autorest/mocks v0.1.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0=
+github.com/Azure/go-autorest/autorest/mocks v0.2.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0=
+github.com/Azure/go-autorest/autorest/mocks v0.3.0 h1:qJumjCaCudz+OcqE9/XtEPfvtOjOmKaui4EOpFI6zZc=
+github.com/Azure/go-autorest/autorest/mocks v0.3.0/go.mod h1:a8FDP3DYzQ4RYfVAxAN3SVSiiO77gL2j2ronKKP0syM=
+github.com/Azure/go-autorest/autorest/to v0.3.0 h1:zebkZaadz7+wIQYgC7GXaz3Wb28yKYfVkkBKwc38VF8=
+github.com/Azure/go-autorest/autorest/to v0.3.0/go.mod h1:MgwOyqaIuKdG4TL/2ywSsIWKAfJfgHDo8ObuUk3t5sA=
+github.com/Azure/go-autorest/autorest/validation v0.2.0 h1:15vMO4y76dehZSq7pAaOLQxC6dZYsSrj2GQpflyM/L4=
+github.com/Azure/go-autorest/autorest/validation v0.2.0/go.mod h1:3EEqHnBxQGHXRYq3HT1WyXAvT7LLY3tl70hw6tQIbjI=
+github.com/Azure/go-autorest/logger v0.1.0 h1:ruG4BSDXONFRrZZJ2GUXDiUyVpayPmb1GnWeHDdaNKY=
+github.com/Azure/go-autorest/logger v0.1.0/go.mod h1:oExouG+K6PryycPJfVSxi/koC6LSNgds39diKLz7Vrc=
+github.com/Azure/go-autorest/tracing v0.5.0 h1:TRn4WjSnkcSy5AEG3pnbtFSwNtwzjr4VYyQflFE619k=
+github.com/Azure/go-autorest/tracing v0.5.0/go.mod h1:r/s2XiOKccPW3HrqB+W0TQzfbtp2fGCgRFtBroKn4Dk=
 github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
@@ -43,8 +73,11 @@ github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMx
 github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
 github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
 github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
+github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
+github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
 github.com/aws/aws-sdk-go v1.17.7/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
 github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
+github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
 github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932/go.mod h1:NOuUCSz6Q9T7+igc/hlvDOUdtWKryOrtFyIVABv/p7k=
 github.com/bkaradzic/go-lz4 v1.0.0/go.mod h1:0YdlkowM3VswSROI7qDxhRvJ3sLhlFrRRwjwegp5jy4=
 github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
@@ -72,6 +105,8 @@ github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumC
 github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
 github.com/dhui/dktest v0.3.2 h1:nZSDcnkpbotzT/nEHNsO+JCKY8i1Qoki1AYOpeLRb6M=
 github.com/dhui/dktest v0.3.2/go.mod h1:l1/ib23a/CmxAe7yixtrYPc8Iy90Zy2udyaHINM5p58=
+github.com/dimchansky/utfbom v1.1.0 h1:FcM3g+nofKgUteL8dm/UpdRXNC9KmADgTpLKsu0TRo4=
+github.com/dimchansky/utfbom v1.1.0/go.mod h1:rO41eb7gLfo8SF1jd9F8HplJm1Fewwi4mQvIirEdv+8=
 github.com/docker/distribution v2.7.0+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
 github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug=
 github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
@@ -90,16 +125,20 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF
 github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
+github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
+github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
 github.com/fsouza/fake-gcs-server v1.17.0/go.mod h1:D1rTE4YCyHFNa99oyJJ5HyclvN/0uQR+pM/VdlL83bw=
 github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
 github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
+github.com/go-ldap/ldap v3.0.2+incompatible/go.mod h1:qfd9rJvER9Q0/D/Sqn1DfHRoBp40uXYvFoEVrNEPqRc=
 github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
 github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
 github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
 github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
+github.com/go-test/deep v1.0.2-0.20181118220953-042da051cf31/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
 github.com/gocql/gocql v0.0.0-20190301043612-f6df8288f9b4/go.mod h1:4Fw1eo5iaEhDUs8XyuhSVCVy52Jq3L+/3GJgYkwc+/0=
 github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE=
 github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
@@ -133,6 +172,7 @@ github.com/golang/protobuf v1.4.0 h1:oOuy+ugB+P/kBdUnG5QaMXSIyJ1q38wWSojYCb3z5VQ
 github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
 github.com/golang/snappy v0.0.0-20170215233205-553a64147049/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
 github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
+github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
 github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
 github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
 github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
@@ -165,10 +205,34 @@ github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB7
 github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4=
 github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
 github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
+github.com/hashicorp/go-cleanhttp v0.5.1 h1:dH3aiDG9Jvb5r5+bYHsikaOUIpcM0xvgMXVoDkXMzJM=
+github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
+github.com/hashicorp/go-hclog v0.0.0-20180709165350-ff2cf002a8dd/go.mod h1:9bjs9uLqI8l75knNv3lV1kA55veR+WUPSiKIWcQHudI=
+github.com/hashicorp/go-hclog v0.8.0/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
+github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
 github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o=
 github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
+github.com/hashicorp/go-plugin v1.0.1/go.mod h1:++UyYGoz3o5w9ZzAdZxtQKrWWP+iqPBn3cQptSMzBuY=
+github.com/hashicorp/go-retryablehttp v0.5.4 h1:1BZvpawXoJCWX6pNtow9+rpEj+3itIlutiqnntI6jOE=
+github.com/hashicorp/go-retryablehttp v0.5.4/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs=
+github.com/hashicorp/go-rootcerts v1.0.1 h1:DMo4fmknnz0E0evoNYnV48RjWndOsmd6OW+09R3cEP8=
+github.com/hashicorp/go-rootcerts v1.0.1/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
+github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc=
+github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A=
+github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
+github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
+github.com/hashicorp/go-version v1.1.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
 github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
 github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
+github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
+github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
+github.com/hashicorp/vault/api v1.0.4 h1:j08Or/wryXT4AcHj1oCbMd7IijXcKzYUGw59LGu9onU=
+github.com/hashicorp/vault/api v1.0.4/go.mod h1:gDcqh3WGcR1cpF5AJz/B1UFheUEneMoIospckxBxk6Q=
+github.com/hashicorp/vault/sdk v0.1.13 h1:mOEPeOhT7jl0J4AMl1E705+BcmeRs1VmKNb9F0sMLy8=
+github.com/hashicorp/vault/sdk v0.1.13/go.mod h1:B+hVj7TpuQY1Y/GPbCpffmgd+tSEwvhkWnjtSYCaS2M=
+github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM=
+github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM=
 github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
 github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
 github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0=
@@ -242,8 +306,10 @@ github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
 github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
 github.com/lib/pq v1.4.0 h1:TmtCFbH+Aw0AixwyttznSMQDgbR5Yed/Gg6S8Funrhc=
 github.com/lib/pq v1.4.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
+github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
 github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
 github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
+github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
 github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
 github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
 github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
@@ -252,12 +318,23 @@ github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOq
 github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
 github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
 github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
+github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
+github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
+github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
+github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
+github.com/mitchellh/go-testing-interface v0.0.0-20171004221916-a61a99592b77/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
+github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
+github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=
+github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
+github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
+github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
 github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
 github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
 github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
 github.com/nakagami/firebirdsql v0.0.0-20190310045651-3c02a58cfed8/go.mod h1:86wM1zFnC6/uDBfZGNwB65O+pR2OFi5q/YQaEUid1qA=
 github.com/neo4j-drivers/gobolt v1.7.4/go.mod h1:O9AUbip4Dgre+CD3p40dnMD4a4r52QBIfblg5k7CTbE=
 github.com/neo4j/neo4j-go-driver v1.7.4/go.mod h1:aPO0vVr+WnhEJne+FgFjfsjzAnssPFLucHgGZ76Zb/U=
+github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
 github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
 github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
 github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
@@ -266,6 +343,8 @@ github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQ
 github.com/opencontainers/image-spec v1.0.1 h1:JMemWkRwHx4Zj+fVxWoMCFm/8sYGGrUVojFA6h/TRcI=
 github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
 github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw=
+github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
+github.com/pierrec/lz4 v2.0.5+incompatible h1:2xWsjqPFWcplujydGg4WmhC/6fZqK42wMM8aXeqhl0I=
 github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
 github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -273,6 +352,7 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
 github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
 github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=
 github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
@@ -288,6 +368,9 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR
 github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
 github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
 github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
+github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
+github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
+github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
 github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
 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=
@@ -343,6 +426,7 @@ golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8U
 golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59 h1:3zb4D3T4G8jdExgVU/95+vQXfpEPiMdCaZgmGVxjNHM=
 golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
@@ -417,11 +501,13 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a h1:WXEvlFVvvGxCJLG6REjsT03iWnKLEWinaScsxF2Vm2o=
 golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190129075346-302c3dd5f1cc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -452,6 +538,7 @@ golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.1-0.20181227161524-e6919f6577db/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
 golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
 golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -549,10 +636,12 @@ google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfG
 google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
 google.golang.org/genproto v0.0.0-20200413115906-b5235f65be36 h1:j7CmVRD4Kec0+f8VuBAc2Ak2MFfXm5Q2/RxuJLL+76E=
 google.golang.org/genproto v0.0.0-20200413115906-b5235f65be36/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
 google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
 google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
+google.golang.org/grpc v1.22.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
 google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
 google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
 google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
@@ -568,6 +657,7 @@ google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miE
 google.golang.org/protobuf v1.21.0 h1:qdOKuR/EIArgaWNjetjgTzgVTAZ+S/WXVrq9HW9zimw=
 google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
 gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
+gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
@@ -576,6 +666,8 @@ gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
 gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
 gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
 gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
+gopkg.in/square/go-jose.v2 v2.3.1 h1:SK5KegNXmKmqE342YYN2qPHEnUYeoMiXXl1poUlI+o4=
+gopkg.in/square/go-jose.v2 v2.3.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
 gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
diff --git a/internal/android/nonce.go b/internal/android/nonce.go
deleted file mode 100644
index 97518d8..0000000
--- a/internal/android/nonce.go
+++ /dev/null
@@ -1,95 +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 android
-
-import (
-	"crypto/sha256"
-	"encoding/base64"
-	"fmt"
-	"sort"
-	"strings"
-
-	"github.com/google/exposure-notifications-server/internal/model"
-)
-
-// Noncer definces an interface for providing Nonce strings.
-type Noncer interface {
-	// Nonce returns the expected nonce given input data.
-	Nonce() string
-}
-
-// Compile-time check to assert NonceData implements the Noncer interface.
-var _ Noncer = (*nonceData)(nil)
-
-type nonceData struct {
-	appPackageName string
-	ttKeysBase64   []model.ExposureKey
-	regions        []string
-	verification   string
-}
-
-// NewNonce Creates a new `Noncer{}` based on the inbound publish request.
-// This ensures that the data in the request is the same data that was used
-// to create the device attestation.
-func NewNonce(publish *model.Publish) Noncer {
-	// base64 keys are to be lexographically sorted
-	sortedKeys := make([]model.ExposureKey, len(publish.Keys))
-	copy(sortedKeys, publish.Keys)
-	sort.Slice(sortedKeys, func(i int, j int) bool {
-		return sortedKeys[i].Key < sortedKeys[j].Key
-	})
-
-	// regions are to be uppercased and then lexographically sorted
-	sortedRegions := make([]string, len(publish.Regions))
-	for i, r := range publish.Regions {
-		sortedRegions[i] = strings.ToUpper(r)
-	}
-	sort.Strings(sortedRegions)
-
-	return &nonceData{
-		appPackageName: publish.AppPackageName,
-		ttKeysBase64:   sortedKeys,
-		regions:        sortedRegions,
-		verification:   publish.VerificationPayload,
-	}
-}
-
-// Nonce returns the expected nonce for this data, from this application.
-func (n *nonceData) Nonce() string {
-	keys := make([]string, 0, len(n.ttKeysBase64))
-	for _, k := range n.ttKeysBase64 {
-		keys = append(keys, fmt.Sprintf("%v.%v.%v.%v", k.Key, k.IntervalNumber, k.IntervalCount, k.TransmissionRisk))
-	}
-
-	// The cleartext is a combination of all of the data on the request
-	// in a specific order.
-	//
-	// appPackageName|key[,key]|region[,region]|verificationAuthorityName
-	// Keys are ancoded as
-	//     base64(exposureKey).itnervalNumber.IntervalCount.transmissionRisk
-	// When there is > 1 key, keys are comma separated.
-	// Keys must in sorted order based on the sorting of the base64 exposure key.
-	// Regions are uppercased, sorted, and comma sepreated
-	cleartext :=
-		n.appPackageName + "|" +
-			strings.Join(keys, ",") + "|" + // where key is b64key.intervalNum.intervalCount
-			strings.Join(n.regions, ",") + "|" +
-			n.verification
-
-	// Take the sha256 checksum of that data
-	sum := sha256.Sum256([]byte(cleartext))
-	// Base64 encode the result.
-	return base64.StdEncoding.EncodeToString(sum[:])
-}
diff --git a/internal/android/safetynet.go b/internal/android/safetynet.go
index f833301..798d7fa 100644
--- a/internal/android/safetynet.go
+++ b/internal/android/safetynet.go
@@ -26,6 +26,7 @@ import (
 
 	"github.com/google/exposure-notifications-server/internal/base64util"
 	"github.com/google/exposure-notifications-server/internal/logging"
+	"github.com/google/exposure-notifications-server/internal/model"
 
 	"github.com/dgrijalva/jwt-go"
 )
@@ -34,7 +35,7 @@ import (
 type VerifyOpts struct {
 	AppPkgName      string
 	APKDigest       []string
-	Nonce           Noncer
+	Nonce           string
 	CTSProfileMatch bool
 	BasicIntegrity  bool
 	MinValidTime    time.Time
@@ -42,10 +43,10 @@ type VerifyOpts struct {
 }
 
 // ValidateAttestation validates the the SafetyNet Attestation from this device
-// matches the properties that we expect based on the applications APIConfig entry.
-// See https://developer.android.com/training/safetynet/attestation#use-response-server
+// matches the properties that we expect based on the AuthorizedApp entry. See
+// https://developer.android.com/training/safetynet/attestation#use-response-server
 // for details on the format of these attestations.
-func ValidateAttestation(ctx context.Context, attestation string, opts VerifyOpts) error {
+func ValidateAttestation(ctx context.Context, attestation string, opts *VerifyOpts) error {
 	defer trace.StartRegion(ctx, "ValidateAttestation").End()
 	logger := logging.FromContext(ctx)
 
@@ -55,7 +56,7 @@ func ValidateAttestation(ctx context.Context, attestation string, opts VerifyOpt
 	}
 
 	// Validate the nonce.
-	if opts.Nonce == nil || opts.Nonce.Nonce() == "" {
+	if opts.Nonce == "" {
 		return fmt.Errorf("missing nonce")
 	}
 	nonceClaimB64, ok := claims["nonce"].(string)
@@ -67,7 +68,7 @@ func ValidateAttestation(ctx context.Context, attestation string, opts VerifyOpt
 		return fmt.Errorf("unable to decode nonce claim data: %w", err)
 	}
 	nonceClaim := string(nonceClaimBytes)
-	nonceCalculated := opts.Nonce.Nonce()
+	nonceCalculated := opts.Nonce
 	if nonceCalculated != nonceClaim {
 		return fmt.Errorf("nonce mismatch: expected %v got %v", nonceCalculated, nonceClaim)
 	}
@@ -136,6 +137,32 @@ func ValidateAttestation(ctx context.Context, attestation string, opts VerifyOpt
 	return nil
 }
 
+// VerifyOptsFor returns the Android SafetyNet verification options to be used
+// based on the AuthorizedApp configuration, request time, and nonce.
+func VerifyOptsFor(c *model.AuthorizedApp, from time.Time, nonce string) *VerifyOpts {
+	digests := make([]string, len(c.ApkDigestSHA256))
+	copy(digests, c.ApkDigestSHA256)
+	rtn := &VerifyOpts{
+		AppPkgName:      c.AppPackageName,
+		CTSProfileMatch: c.CTSProfileMatch,
+		BasicIntegrity:  c.BasicIntegrity,
+		APKDigest:       digests,
+		Nonce:           nonce,
+	}
+
+	// Calculate the valid time window based on now + config options.
+	if c.AllowedPastTime > 0 {
+		minTime := from.Add(-c.AllowedPastTime)
+		rtn.MinValidTime = minTime
+	}
+	if c.AllowedFutureTime > 0 {
+		maxTime := from.Add(c.AllowedFutureTime)
+		rtn.MaxValidTime = maxTime
+	}
+
+	return rtn
+}
+
 // The keyFunc is based on the Android sample code
 // https://github.com/googlesamples/android-play-safetynet/blob/d7513a54e2f28c0dcd7f8d8d0fa03adb5d87b91a/server/java/src/main/java/OfflineVerify.java
 func keyFunc(ctx context.Context, tok *jwt.Token) (interface{}, error) {
diff --git a/internal/authorizedapp/config.go b/internal/authorizedapp/config.go
new file mode 100644
index 0000000..75fbac6
--- /dev/null
+++ b/internal/authorizedapp/config.go
@@ -0,0 +1,30 @@
+// 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 authorizedapp
+
+import (
+	"time"
+)
+
+type Config struct {
+	// CacheDuration is the amount of time AuthorizedApp should be cached before
+	// being re-read from their provider.
+	CacheDuration time.Duration `envconfig:"AUTHORIZED_APP_CACHE_DURATION" default:"5m"`
+}
+
+// AuthorizedApp implements an interface for setup.
+func (c *Config) AuthorizedApp() *Config {
+	return c
+}
diff --git a/internal/authorizedapp/database_provider.go b/internal/authorizedapp/database_provider.go
new file mode 100644
index 0000000..fcd0233
--- /dev/null
+++ b/internal/authorizedapp/database_provider.go
@@ -0,0 +1,141 @@
+// 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 authorizedapp
+
+import (
+	"context"
+	"fmt"
+	"sync"
+	"time"
+
+	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/logging"
+	"github.com/google/exposure-notifications-server/internal/model"
+	"github.com/google/exposure-notifications-server/internal/secrets"
+)
+
+// Compile-time check to assert implementation.
+var _ Provider = (*DatabaseProvider)(nil)
+
+// DatabaseProvider is a Provider that pulls from the database and caches and
+// refreshes values on failure.
+type DatabaseProvider struct {
+	database      *database.DB
+	secretManager secrets.SecretManager
+	cacheDuration time.Duration
+
+	cache     map[string]*cacheItem
+	cacheLock sync.RWMutex
+}
+
+type cacheItem struct {
+	value    *model.AuthorizedApp
+	cachedAt time.Time
+}
+
+// DatabaseProviderOption is used as input to the database provider.
+type DatabaseProviderOption func(*DatabaseProvider) *DatabaseProvider
+
+// WithSecretManager sets the secret manager for resolving secrets.
+func WithSecretManager(sm secrets.SecretManager) DatabaseProviderOption {
+	return func(p *DatabaseProvider) *DatabaseProvider {
+		p.secretManager = sm
+		return p
+	}
+}
+
+// NewDatabaseProvider creates a new Provider that reads from a database.
+func NewDatabaseProvider(ctx context.Context, db *database.DB, config *Config, opts ...DatabaseProviderOption) (Provider, error) {
+	provider := &DatabaseProvider{
+		database:      db,
+		cacheDuration: config.CacheDuration,
+		cache:         make(map[string]*cacheItem),
+	}
+
+	// Apply options.
+	for _, opt := range opts {
+		provider = opt(provider)
+	}
+
+	return provider, nil
+}
+
+// AppConfig returns the config for the given app package name.
+func (p *DatabaseProvider) AppConfig(ctx context.Context, name string) (*model.AuthorizedApp, error) {
+	logger := logging.FromContext(ctx)
+
+	// Acquire a read lock first, which allows concurrent readers, to check if
+	// there's an item in the cache.
+	p.cacheLock.RLock()
+	item, ok := p.cache[name]
+	if ok && time.Since(item.cachedAt) <= p.cacheDuration {
+		if item.value == nil {
+			p.cacheLock.RUnlock()
+			return nil, AppNotFound
+		}
+		p.cacheLock.RUnlock()
+		return item.value, nil
+	}
+	p.cacheLock.RUnlock()
+
+	// Acquire a more aggressive lock now because we're about to mutate. However,
+	// it's possible that a concurrent routine has already mutated between our
+	// read and write locks, so we have to check again.
+	p.cacheLock.Lock()
+	item, ok = p.cache[name]
+	if ok && time.Since(item.cachedAt) <= p.cacheDuration {
+		if item.value == nil {
+			p.cacheLock.Unlock()
+			return nil, AppNotFound
+		}
+		p.cacheLock.Unlock()
+		return item.value, nil
+	}
+
+	// Load config.
+	config, err := p.loadAuthorizedAppFromDatabase(ctx, name)
+	if err != nil {
+		return nil, fmt.Errorf("authorizedapp: %w", err)
+	}
+
+	// Cache configs.
+	logger.Infof("authorizedapp: loaded %v, caching for %s", name, p.cacheDuration)
+	p.cache[name] = &cacheItem{
+		value:    config,
+		cachedAt: time.Now(),
+	}
+
+	// Handle not found.
+	if config == nil {
+		p.cacheLock.Unlock()
+		return nil, AppNotFound
+	}
+
+	// Returned config.
+	return config, nil
+}
+
+// loadAuthorizedAppFromDatabase is a lower-level private API that actually loads and parses
+// a single AuthorizedApp from the database.
+func (p *DatabaseProvider) loadAuthorizedAppFromDatabase(ctx context.Context, name string) (*model.AuthorizedApp, error) {
+	logger := logging.FromContext(ctx)
+
+	logger.Infof("authorizedapp: loading %v from database", name)
+	config, err := p.database.GetAuthorizedApp(ctx, p.secretManager, name)
+	if err != nil {
+		return nil, fmt.Errorf("failed to read %v from database: %w", name, err)
+	}
+	return config, nil
+}
diff --git a/internal/authorizedapp/memory_provider.go b/internal/authorizedapp/memory_provider.go
new file mode 100644
index 0000000..31749c2
--- /dev/null
+++ b/internal/authorizedapp/memory_provider.go
@@ -0,0 +1,48 @@
+// 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 authorizedapp
+
+import (
+	"context"
+
+	"github.com/google/exposure-notifications-server/internal/model"
+)
+
+// Compile-time check to assert implementation.
+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
+}
+
+// NewMemoryProvider creates a new Provider that reads from a database.
+func NewMemoryProvider(ctx context.Context, _ *Config) (Provider, error) {
+	provider := &MemoryProvider{
+		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) {
+	val, ok := p.Data[name]
+	if !ok {
+		return nil, AppNotFound
+	}
+	return val, nil
+}
diff --git a/internal/authorizedapp/provider.go b/internal/authorizedapp/provider.go
new file mode 100644
index 0000000..390c37c
--- /dev/null
+++ b/internal/authorizedapp/provider.go
@@ -0,0 +1,34 @@
+// 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 authorizedapp
+
+import (
+	"context"
+	"errors"
+
+	"github.com/google/exposure-notifications-server/internal/model"
+)
+
+// AppNotFound is the sentinel error returned when AppConfig fails to find an
+// app with the given name.
+var AppNotFound = errors.New("app not found")
+
+// Provider defines possible AuthorizedApp providers.
+type Provider interface {
+	// AppConfig returns the application-specific configuration for the given
+	// name. An error is returned if the configuration fails to load. An error is
+	// returned if no app with the given name is registered in the system.
+	AppConfig(ctx context.Context, name string) (*model.AuthorizedApp, error)
+}
diff --git a/internal/database/apiconfig.go b/internal/database/apiconfig.go
deleted file mode 100644
index 13ee544..0000000
--- a/internal/database/apiconfig.go
+++ /dev/null
@@ -1,122 +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 database
-
-import (
-	"context"
-	"database/sql"
-	"fmt"
-	"time"
-
-	"github.com/google/exposure-notifications-server/internal/ios"
-	"github.com/google/exposure-notifications-server/internal/model/apiconfig"
-	"github.com/google/exposure-notifications-server/internal/secrets"
-)
-
-// ReadAPIConfigs loads all APIConfig values from the database.
-func (db *DB) ReadAPIConfigs(ctx context.Context, sm secrets.SecretManager) ([]*apiconfig.APIConfig, error) {
-	conn, err := db.pool.Acquire(ctx)
-	if err != nil {
-		return nil, fmt.Errorf("acquiring connection: %v", err)
-	}
-	defer conn.Release()
-
-	query := `
-		SELECT
-			app_package_name, platform, apk_digest, cts_profile_match, basic_integrity,
-			allowed_past_seconds, allowed_future_seconds, allowed_regions, all_regions,
-			ios_devicecheck_team_id_secret, ios_devicecheck_key_id_secret, ios_devicecheck_private_key_secret
-		FROM
-			APIConfig`
-	rows, err := conn.Query(ctx, query)
-	if err != nil {
-		return nil, err
-	}
-	defer rows.Close()
-
-	// In most instances, we expect a single config entry.
-	var result []*apiconfig.APIConfig
-	for rows.Next() {
-		if err := rows.Err(); err != nil {
-			return nil, fmt.Errorf("iterating rows: %w", err)
-		}
-
-		var regions []string
-		config := apiconfig.New()
-		var allowedPastSeconds, allowedFutureSeconds *int
-		var deviceCheckTeamIDSecret, deviceCheckKeyIDSecret, deviceCheckPrivateKeySecret sql.NullString
-		if err := rows.Scan(&config.AppPackageName, &config.Platform, &config.ApkDigestSHA256,
-			&config.CTSProfileMatch, &config.BasicIntegrity,
-			&allowedPastSeconds, &allowedFutureSeconds, &regions, &config.AllowAllRegions,
-			&deviceCheckTeamIDSecret, &deviceCheckKeyIDSecret, &deviceCheckPrivateKeySecret); err != nil {
-			return nil, err
-		}
-
-		// Convert time in seconds from DB into time.Duration
-		if allowedPastSeconds != nil {
-			d := time.Duration(*allowedPastSeconds) * time.Second
-			config.AllowedPastTime = d
-		}
-		if allowedFutureSeconds != nil {
-			d := time.Duration(*allowedFutureSeconds) * time.Second
-			config.AllowedFutureTime = d
-		}
-
-		// build the regions map
-		for _, r := range regions {
-			config.AllowedRegions[r] = true
-		}
-
-		// Resolve secrets to their plaintext values
-		if v := deviceCheckTeamIDSecret; v.Valid && v.String != "" {
-			plaintext, err := sm.GetSecretValue(ctx, v.String)
-			if err != nil {
-				return nil, fmt.Errorf("ios_devicecheck_team_id_secret at %s (%s): %w",
-					config.AppPackageName, config.Platform, err)
-			}
-			config.DeviceCheckTeamID = plaintext
-		}
-
-		if v := deviceCheckKeyIDSecret; v.Valid && v.String != "" {
-			plaintext, err := sm.GetSecretValue(ctx, v.String)
-			if err != nil {
-				return nil, fmt.Errorf("ios_devicecheck_key_id_secret at %s (%s): %w",
-					config.AppPackageName, config.Platform, err)
-			}
-			config.DeviceCheckKeyID = plaintext
-		}
-
-		if v := deviceCheckPrivateKeySecret; v.Valid && v.String != "" {
-			plaintext, err := sm.GetSecretValue(ctx, v.String)
-			if err != nil {
-				return nil, fmt.Errorf("ios_devicecheck_private_key_secret at %s (%s): %w",
-					config.AppPackageName, config.Platform, err)
-			}
-
-			key, err := ios.ParsePrivateKey(plaintext)
-			if err != nil {
-				return nil, fmt.Errorf("failed to parse private key at %s (%s): %w",
-					config.AppPackageName, config.Platform, err)
-			}
-			config.DeviceCheckPrivateKey = key
-		}
-
-		result = append(result, config)
-	}
-	if err := rows.Err(); err != nil {
-		return nil, err
-	}
-	return result, nil
-}
diff --git a/internal/database/authorized_app.go b/internal/database/authorized_app.go
new file mode 100644
index 0000000..6dbb2c5
--- /dev/null
+++ b/internal/database/authorized_app.go
@@ -0,0 +1,114 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package database
+
+import (
+	"context"
+	"database/sql"
+	"fmt"
+	"time"
+
+	"github.com/google/exposure-notifications-server/internal/ios"
+	"github.com/google/exposure-notifications-server/internal/model"
+	"github.com/google/exposure-notifications-server/internal/secrets"
+	pgx "github.com/jackc/pgx/v4"
+)
+
+// GetAuthorizedApp loads a single AuthorizedApp for the given name. If no row
+// exists, this returns nil.
+func (db *DB) GetAuthorizedApp(ctx context.Context, sm secrets.SecretManager, name string) (*model.AuthorizedApp, error) {
+	conn, err := db.pool.Acquire(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("acquiring connection: %v", err)
+	}
+	defer conn.Release()
+
+	query := `
+		SELECT
+			app_package_name, platform, apk_digest, cts_profile_match, basic_integrity,
+			allowed_past_seconds, allowed_future_seconds, allowed_regions, all_regions,
+			ios_devicecheck_team_id_secret, ios_devicecheck_key_id_secret, ios_devicecheck_private_key_secret
+		FROM
+			AuthorizedApp
+		WHERE app_package_name = $1`
+
+	row := conn.QueryRow(ctx, query, name)
+
+	config := model.NewAuthorizedApp()
+	var allowedRegions []string
+	var allowedPastSeconds, allowedFutureSeconds *int
+	var deviceCheckTeamIDSecret, deviceCheckKeyIDSecret, deviceCheckPrivateKeySecret sql.NullString
+	if err := row.Scan(
+		&config.AppPackageName, &config.Platform, &config.ApkDigestSHA256, &config.CTSProfileMatch, &config.BasicIntegrity,
+		&allowedPastSeconds, &allowedFutureSeconds, &allowedRegions, &config.AllowAllRegions,
+		&deviceCheckTeamIDSecret, &deviceCheckKeyIDSecret, &deviceCheckPrivateKeySecret,
+	); err != nil {
+		if err == pgx.ErrNoRows {
+			return nil, nil
+		}
+		return nil, err
+	}
+
+	// Convert time in seconds from DB into time.Duration
+	if allowedPastSeconds != nil {
+		d := time.Duration(*allowedPastSeconds) * time.Second
+		config.AllowedPastTime = d
+	}
+	if allowedFutureSeconds != nil {
+		d := time.Duration(*allowedFutureSeconds) * time.Second
+		config.AllowedFutureTime = d
+	}
+
+	// build the regions map
+	for _, r := range allowedRegions {
+		config.AllowedRegions[r] = struct{}{}
+	}
+
+	// Resolve secrets to their plaintext values
+	if v := deviceCheckTeamIDSecret; v.Valid && v.String != "" {
+		plaintext, err := sm.GetSecretValue(ctx, v.String)
+		if err != nil {
+			return nil, fmt.Errorf("ios_devicecheck_team_id_secret at %s (%s): %w",
+				config.AppPackageName, config.Platform, err)
+		}
+		config.DeviceCheckTeamID = plaintext
+	}
+
+	if v := deviceCheckKeyIDSecret; v.Valid && v.String != "" {
+		plaintext, err := sm.GetSecretValue(ctx, v.String)
+		if err != nil {
+			return nil, fmt.Errorf("ios_devicecheck_key_id_secret at %s (%s): %w",
+				config.AppPackageName, config.Platform, err)
+		}
+		config.DeviceCheckKeyID = plaintext
+	}
+
+	if v := deviceCheckPrivateKeySecret; v.Valid && v.String != "" {
+		plaintext, err := sm.GetSecretValue(ctx, v.String)
+		if err != nil {
+			return nil, fmt.Errorf("ios_devicecheck_private_key_secret at %s (%s): %w",
+				config.AppPackageName, config.Platform, err)
+		}
+
+		key, err := ios.ParsePrivateKey(plaintext)
+		if err != nil {
+			return nil, fmt.Errorf("failed to parse private key at %s (%s): %w",
+				config.AppPackageName, config.Platform, err)
+		}
+		config.DeviceCheckPrivateKey = key
+	}
+
+	return config, nil
+}
diff --git a/internal/database/export.go b/internal/database/export.go
index cd5cc03..29edcb5 100644
--- a/internal/database/export.go
+++ b/internal/database/export.go
@@ -16,7 +16,6 @@ package database
 
 import (
 	"context"
-	"database/sql"
 	"errors"
 	"fmt"
 	"math/rand"
@@ -54,11 +53,12 @@ func (db *DB) AddExportConfig(ctx context.Context, ec *model.ExportConfig) error
 		row := tx.QueryRow(ctx, `
 			INSERT INTO
 				ExportConfig
-				(bucket_name, filename_root, period_seconds, region, from_timestamp, thru_timestamp, signing_key)
+				(bucket_name, filename_root, period_seconds, region, from_timestamp, thru_timestamp, signature_info_ids)
 			VALUES
 				($1, $2, $3, $4, $5, $6, $7)
 			RETURNING config_id
-		`, ec.BucketName, ec.FilenameRoot, int(ec.Period.Seconds()), ec.Region, ec.From, thru, toNullString(ec.SigningKey))
+		`, ec.BucketName, ec.FilenameRoot, int(ec.Period.Seconds()), ec.Region,
+			ec.From, thru, ec.SignatureInfoIDs)
 
 		if err := row.Scan(&ec.ConfigID); err != nil {
 			return fmt.Errorf("fetching config_id: %w", err)
@@ -85,7 +85,7 @@ func (db *DB) IterateExportConfigs(ctx context.Context, t time.Time, f func(*mod
 
 	rows, err := conn.Query(ctx, `
 		SELECT
-			config_id, bucket_name, filename_root, period_seconds, region, from_timestamp, thru_timestamp, signing_key
+			config_id, bucket_name, filename_root, period_seconds, region, from_timestamp, thru_timestamp, signature_info_ids
 		FROM
 			ExportConfig
 		WHERE
@@ -102,16 +102,14 @@ func (db *DB) IterateExportConfigs(ctx context.Context, t time.Time, f func(*mod
 			m             model.ExportConfig
 			periodSeconds int
 			thru          *time.Time
-			skey          sql.NullString
 		)
-		if err := rows.Scan(&m.ConfigID, &m.BucketName, &m.FilenameRoot, &periodSeconds, &m.Region, &m.From, &thru, &skey); err != nil {
+		if err := rows.Scan(&m.ConfigID, &m.BucketName, &m.FilenameRoot, &periodSeconds, &m.Region, &m.From, &thru, &m.SignatureInfoIDs); err != nil {
 			return err
 		}
 		m.Period = time.Duration(periodSeconds) * time.Second
 		if thru != nil {
 			m.Thru = *thru
 		}
-		m.SigningKey = skey.String
 		if err := f(&m); err != nil {
 			return err
 		}
@@ -119,6 +117,70 @@ func (db *DB) IterateExportConfigs(ctx context.Context, t time.Time, f func(*mod
 	return rows.Err()
 }
 
+func (db *DB) AddSignatureInfo(ctx context.Context, si *model.SignatureInfo) error {
+	if si.SigningKey == "" {
+		return fmt.Errorf("signing key cannot be empty for a signature info")
+	}
+
+	var thru *time.Time
+	if !si.EndTimestamp.IsZero() {
+		thru = &si.EndTimestamp
+	}
+	return db.inTx(ctx, pgx.Serializable, func(tx pgx.Tx) error {
+		row := tx.QueryRow(ctx, `
+      INSERT INTO
+        SignatureInfo
+        (signing_key, app_package_name, bundle_id, signing_key_version, signing_key_id, thru_timestamp)
+      VALUES
+        ($1, $2, $3, $4, $5, $6)
+      RETURNING id
+    `, si.SigningKey, si.AppPackageName, si.BundleID, si.SigningKeyVersion, si.SigningKeyID, thru)
+
+		if err := row.Scan(&si.ID); err != nil {
+			return fmt.Errorf("fetching id: %w", err)
+		}
+		return nil
+	})
+}
+
+func (db *DB) LookupSignatureInfos(ctx context.Context, ids []int64, validUntil time.Time) ([]*model.SignatureInfo, error) {
+	conn, err := db.pool.Acquire(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("acquiring connection: %w", err)
+	}
+	defer conn.Release()
+
+	rows, err := conn.Query(ctx, `
+    SELECT
+      id, signing_key, app_package_name, bundle_id, signing_key_version, signing_key_id, thru_timestamp
+    FROM
+      SignatureInfo
+    WHERE
+      id = any($1) AND (thru_timestamp is NULL OR thru_timestamp >= $2)
+  `, ids, validUntil)
+	if err != nil {
+		return nil, err
+	}
+
+	var sigInfos []*model.SignatureInfo
+	for rows.Next() {
+		if rows.Err() != nil {
+			return nil, rows.Err()
+		}
+		var info model.SignatureInfo
+		var thru *time.Time
+		if err := rows.Scan(&info.ID, &info.SigningKey, &info.AppPackageName, &info.BundleID, &info.SigningKeyVersion, &info.SigningKeyID, &thru); err != nil {
+			return nil, err
+		}
+		if thru != nil {
+			info.EndTimestamp = *thru
+		}
+		sigInfos = append(sigInfos, &info)
+	}
+
+	return sigInfos, nil
+}
+
 // LatestExportBatchEnd returns the end time of the most recent ExportBatch for
 // a given ExportConfig. It returns the zero time if no previous ExportBatch
 // exists.
@@ -159,7 +221,7 @@ func (db *DB) AddExportBatches(ctx context.Context, batches []*model.ExportBatch
 		_, err := tx.Prepare(ctx, stmtName, `
 			INSERT INTO
 				ExportBatch
-				(config_id, bucket_name, filename_root, start_timestamp, end_timestamp, region, status, signing_key)
+				(config_id, bucket_name, filename_root, start_timestamp, end_timestamp, region, status, signature_info_ids)
 			VALUES
 				($1, $2, $3, $4, $5, $6, $7, $8)
 		`)
@@ -169,7 +231,7 @@ func (db *DB) AddExportBatches(ctx context.Context, batches []*model.ExportBatch
 
 		for _, eb := range batches {
 			if _, err := tx.Exec(ctx, stmtName,
-				eb.ConfigID, eb.BucketName, eb.FilenameRoot, eb.StartTimestamp, eb.EndTimestamp, eb.Region, eb.Status, eb.SigningKey); err != nil {
+				eb.ConfigID, eb.BucketName, eb.FilenameRoot, eb.StartTimestamp, eb.EndTimestamp, eb.Region, eb.Status, eb.SignatureInfoIDs); err != nil {
 				return err
 			}
 		}
@@ -299,7 +361,7 @@ func (db *DB) LookupExportBatch(ctx context.Context, batchID int64) (*model.Expo
 func lookupExportBatch(ctx context.Context, batchID int64, queryRow queryRowFn) (*model.ExportBatch, error) {
 	row := queryRow(ctx, `
 		SELECT
-			batch_id, config_id, bucket_name, filename_root, start_timestamp, end_timestamp, region, status, lease_expires, signing_key
+			batch_id, config_id, bucket_name, filename_root, start_timestamp, end_timestamp, region, status, lease_expires, signature_info_ids
 		FROM
 			ExportBatch
 		WHERE
@@ -309,7 +371,7 @@ func lookupExportBatch(ctx context.Context, batchID int64, queryRow queryRowFn)
 
 	var expires *time.Time
 	eb := model.ExportBatch{}
-	if err := row.Scan(&eb.BatchID, &eb.ConfigID, &eb.BucketName, &eb.FilenameRoot, &eb.StartTimestamp, &eb.EndTimestamp, &eb.Region, &eb.Status, &expires, &eb.SigningKey); err != nil {
+	if err := row.Scan(&eb.BatchID, &eb.ConfigID, &eb.BucketName, &eb.FilenameRoot, &eb.StartTimestamp, &eb.EndTimestamp, &eb.Region, &eb.Status, &expires, &eb.SignatureInfoIDs); err != nil {
 		if err == pgx.ErrNoRows {
 			return nil, ErrNotFound
 		}
diff --git a/internal/dbapiconfig/apiconfig.go b/internal/dbapiconfig/apiconfig.go
deleted file mode 100644
index 7965b4f..0000000
--- a/internal/dbapiconfig/apiconfig.go
+++ /dev/null
@@ -1,126 +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 dbapiconfig
-
-import (
-	"context"
-	"fmt"
-	"sync"
-	"time"
-
-	provider "github.com/google/exposure-notifications-server/internal/config"
-	"github.com/google/exposure-notifications-server/internal/database"
-	"github.com/google/exposure-notifications-server/internal/logging"
-	"github.com/google/exposure-notifications-server/internal/model/apiconfig"
-	"github.com/google/exposure-notifications-server/internal/secrets"
-)
-
-// Compile-time check to assert Config implements the Provider interface.
-var _ provider.Provider = (*dbConfigProvider)(nil)
-
-type dbConfigProvider struct {
-	db            *database.DB
-	sm            secrets.SecretManager
-	refreshPeriod time.Duration
-
-	bypassSafetyNet   bool
-	bypassDeviceCheck bool
-
-	mu           sync.RWMutex
-	lastLoadTime time.Time
-	cache        map[string]*apiconfig.APIConfig
-}
-
-type ConfigOpts struct {
-	RefreshDuration   time.Duration `envconfig:"API_CONFIG_REFRESH" default:"5m"`
-	BypassSafetyNet   bool          `envconfig:"BYPASS_SAFETYNET"`
-	BypassDeviceCheck bool          `envconfig:"BYPASS_DEVICECHECK"`
-}
-
-func NewConfigProvider(db *database.DB, sm secrets.SecretManager, opts *ConfigOpts) (provider.Provider, error) {
-	ctx := context.Background()
-	logger := logging.FromContext(ctx)
-
-	cfg := &dbConfigProvider{
-		db:                db,
-		sm:                sm,
-		refreshPeriod:     opts.RefreshDuration,
-		bypassSafetyNet:   opts.BypassSafetyNet,
-		bypassDeviceCheck: opts.BypassDeviceCheck,
-		cache:             make(map[string]*apiconfig.APIConfig),
-	}
-
-	// TODO (mikehelmick): Load device check private key from secret store.
-
-	if cfg.refreshPeriod <= 0 {
-		return nil, fmt.Errorf("refresh duration must be a positive duration")
-	}
-	if cfg.refreshPeriod > time.Minute*5 {
-		logger.Warnf("config refresh duration is > 5 minutes: %v", cfg.refreshPeriod)
-	}
-
-	if cfg.bypassSafetyNet {
-		logger.Errorf("APIConfig provider will bypass SafetyNet verification for android devices. This should only be done in test environments.")
-	}
-	if cfg.bypassDeviceCheck {
-		logger.Errorf("APIConfig provider will bypass DeviceCheck verification for iOS devices. This should only be done in test environments.")
-	}
-
-	return cfg, nil
-}
-
-func (c *dbConfigProvider) loadConfig(ctx context.Context) error {
-	// In case multiple requests notice expiration simultaneously, only do it once.
-	c.mu.Lock()
-	defer c.mu.Unlock()
-
-	// if the cache isn't expired, don't reload.
-	if time.Since(c.lastLoadTime) < c.refreshPeriod {
-		return nil
-	}
-
-	logger := logging.FromContext(ctx)
-
-	configs, err := c.db.ReadAPIConfigs(ctx, c.sm)
-	if err != nil {
-		return err
-	}
-
-	c.cache = make(map[string]*apiconfig.APIConfig)
-	for _, apiConfig := range configs {
-		apiConfig.BypassSafetyNet = c.bypassSafetyNet
-		c.cache[apiConfig.AppPackageName] = apiConfig
-	}
-	logger.Info("loaded new APIConfig values")
-	c.lastLoadTime = time.Now()
-	return nil
-}
-
-func (c *dbConfigProvider) AppPkgConfig(ctx context.Context, appPkg string) (*apiconfig.APIConfig, error) {
-	if err := c.loadConfig(ctx); err != nil {
-		return nil, err
-	}
-
-	c.mu.RLock()
-	defer c.mu.RUnlock()
-
-	cfg, ok := c.cache[appPkg]
-	if !ok {
-		logger := logging.FromContext(ctx)
-		logger.Errorf("requested config for unconfigured app: %v", appPkg)
-		return nil, nil
-	}
-	return cfg, nil
-}
diff --git a/internal/export/batcher.go b/internal/export/batcher.go
index c620e85..b12f568 100644
--- a/internal/export/batcher.go
+++ b/internal/export/batcher.go
@@ -107,15 +107,17 @@ func (s *Server) maybeCreateBatches(ctx context.Context, ec *model.ExportConfig,
 
 	var batches []*model.ExportBatch
 	for _, br := range ranges {
+		infoIds := make([]int64, len(ec.SignatureInfoIDs))
+		copy(infoIds, ec.SignatureInfoIDs)
 		batches = append(batches, &model.ExportBatch{
-			ConfigID:       ec.ConfigID,
-			BucketName:     ec.BucketName,
-			FilenameRoot:   ec.FilenameRoot,
-			StartTimestamp: br.start,
-			EndTimestamp:   br.end,
-			Region:         ec.Region,
-			Status:         model.ExportBatchOpen,
-			SigningKey:     ec.SigningKey,
+			ConfigID:         ec.ConfigID,
+			BucketName:       ec.BucketName,
+			FilenameRoot:     ec.FilenameRoot,
+			StartTimestamp:   br.start,
+			EndTimestamp:     br.end,
+			Region:           ec.Region,
+			Status:           model.ExportBatchOpen,
+			SignatureInfoIDs: infoIds,
 		})
 	}
 
diff --git a/internal/export/config.go b/internal/export/config.go
index 0d3ee77..52bc783 100644
--- a/internal/export/config.go
+++ b/internal/export/config.go
@@ -29,16 +29,14 @@ var _ setup.DBConfigProvider = (*Config)(nil)
 // Config represents the configuration and associated environment variables for
 // the export components.
 type Config struct {
-	Database          *database.Config
-	Port              string        `envconfig:"PORT" default:"8080"`
-	CreateTimeout     time.Duration `envconfig:"CREATE_BATCHES_TIMEOUT" default:"5m"`
-	WorkerTimeout     time.Duration `envconfig:"WORKER_TIMEOUT" default:"5m"`
-	MinRecords        int           `envconfig:"EXPORT_FILE_MIN_RECORDS" default:"1000"`
-	PaddingRange      int           `envconfig:"EXPORT_FILE_PADDING_RANGE" default:"100"`
-	MaxRecords        int           `envconfig:"EXPORT_FILE_MAX_RECORDS" default:"30000"`
-	DefaultKeyID      string        `envconfig:"EXPORT_FILE_DEFAULT_KEY_ID" default:"ExampleServer"`
-	DefaultKeyVersion string        `envconfig:"EXPORT_FILE_DEFAULT_KEY_VERSION" default:"1"`
-	TruncateWindow    time.Duration `envconfig:"TRUNCATE_WINDOW" default:"1h"`
+	Database       *database.Config
+	Port           string        `envconfig:"PORT" default:"8080"`
+	CreateTimeout  time.Duration `envconfig:"CREATE_BATCHES_TIMEOUT" default:"5m"`
+	WorkerTimeout  time.Duration `envconfig:"WORKER_TIMEOUT" default:"5m"`
+	MinRecords     int           `envconfig:"EXPORT_FILE_MIN_RECORDS" default:"1000"`
+	PaddingRange   int           `envconfig:"EXPORT_FILE_PADDING_RANGE" default:"100"`
+	MaxRecords     int           `envconfig:"EXPORT_FILE_MAX_RECORDS" default:"30000"`
+	TruncateWindow time.Duration `envconfig:"TRUNCATE_WINDOW" default:"1h"`
 }
 
 // DB returns the database config.
diff --git a/internal/export/exportfile.go b/internal/export/exportfile.go
index 97e2b90..740fdd7 100644
--- a/internal/export/exportfile.go
+++ b/internal/export/exportfile.go
@@ -38,16 +38,21 @@ const (
 	algorithm = "1.2.840.10045.4.3.2"
 )
 
+type exportSigners struct {
+	signatureInfo *model.SignatureInfo
+	signer        crypto.Signer
+}
+
 // MarshalExportFile converts the inputs into an encoded byte array.
-func MarshalExportFile(eb *model.ExportBatch, exposures []*model.Exposure, batchNum, batchSize int, signer crypto.Signer, keyId, keyVersion string) ([]byte, error) {
+func MarshalExportFile(eb *model.ExportBatch, exposures []*model.Exposure, batchNum, batchSize int, signers []exportSigners) ([]byte, error) {
 	// create main exposure key export binary
-	expContents, err := marshalContents(eb, exposures, int32(batchNum), int32(batchSize), keyId, keyVersion)
+	expContents, err := marshalContents(eb, exposures, int32(batchNum), int32(batchSize), signers)
 	if err != nil {
 		return nil, fmt.Errorf("unable to marshal exposure keys: %w", err)
 	}
 
 	// create signature file
-	sigContents, err := marshalSignature(expContents, int32(batchNum), int32(batchSize), signer, keyId, keyVersion)
+	sigContents, err := marshalSignature(eb, expContents, int32(batchNum), int32(batchSize), signers)
 	if err != nil {
 		return nil, fmt.Errorf("unable to marshal signature file: %w", err)
 	}
@@ -77,7 +82,7 @@ func MarshalExportFile(eb *model.ExportBatch, exposures []*model.Exposure, batch
 	return buf.Bytes(), nil
 }
 
-func marshalContents(eb *model.ExportBatch, exposures []*model.Exposure, batchNum int32, batchSize int32, keyId, keyVersion string) ([]byte, error) {
+func marshalContents(eb *model.ExportBatch, exposures []*model.Exposure, batchNum int32, batchSize int32, signers []exportSigners) ([]byte, error) {
 	exportBytes := []byte("EK Export v1    ")
 	if len(exportBytes) != fixedHeaderWidth {
 		return nil, fmt.Errorf("incorrect header length: %d", len(exportBytes))
@@ -102,6 +107,12 @@ func marshalContents(eb *model.ExportBatch, exposures []*model.Exposure, batchNu
 		}
 		pbeks = append(pbeks, &pbek)
 	}
+
+	var exportSigInfos []*export.SignatureInfo
+	for _, si := range signers {
+		exportSigInfos = append(exportSigInfos, createSignatureInfo(si.signatureInfo))
+	}
+
 	pbeke := export.TemporaryExposureKeyExport{
 		StartTimestamp: proto.Uint64(uint64(eb.StartTimestamp.Unix())),
 		EndTimestamp:   proto.Uint64(uint64(eb.EndTimestamp.Unix())),
@@ -109,13 +120,7 @@ func marshalContents(eb *model.ExportBatch, exposures []*model.Exposure, batchNu
 		BatchNum:       proto.Int32(int32(batchNum)),
 		BatchSize:      proto.Int32(int32(batchSize)),
 		Keys:           pbeks,
-		SignatureInfos: []*export.SignatureInfo{
-			{
-				VerificationKeyId:      proto.String(keyId),
-				VerificationKeyVersion: proto.String(keyVersion),
-				SignatureAlgorithm:     proto.String(algorithm),
-			},
-		},
+		SignatureInfos: exportSigInfos,
 	}
 	protoBytes, err := proto.Marshal(&pbeke)
 	if err != nil {
@@ -124,23 +129,41 @@ func marshalContents(eb *model.ExportBatch, exposures []*model.Exposure, batchNu
 	return append(exportBytes, protoBytes...), nil
 }
 
-func marshalSignature(exportContents []byte, batchNum int32, batchSize int32, signer crypto.Signer, keyId, keyVersion string) ([]byte, error) {
-	sig, err := generateSignature(exportContents, signer)
-	if err != nil {
-		return nil, fmt.Errorf("unable to generate signature: %v", err)
-	}
-	teks := &export.TEKSignature{
-		SignatureInfo: &export.SignatureInfo{
-			VerificationKeyId:      proto.String(keyId),
-			VerificationKeyVersion: proto.String(keyVersion),
-			SignatureAlgorithm:     proto.String(algorithm),
-		},
-		BatchNum:  proto.Int32(batchNum),
-		BatchSize: proto.Int32(batchSize),
-		Signature: sig,
+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)
+	}
+	if si.SigningKeyID != "" {
+		sigInfo.VerificationKeyId = proto.String(si.SigningKeyID)
+	}
+	return sigInfo
+}
+
+func marshalSignature(eb *model.ExportBatch, exportContents []byte, batchNum int32, batchSize int32, signers []exportSigners) ([]byte, error) {
+
+	var signatures []*export.TEKSignature
+	for _, s := range signers {
+		sig, err := generateSignature(exportContents, s.signer)
+		if err != nil {
+			return nil, fmt.Errorf("unable to generate signature: %v", err)
+		}
+		teks := &export.TEKSignature{
+			SignatureInfo: createSignatureInfo(s.signatureInfo),
+			BatchNum:      proto.Int32(batchNum),
+			BatchSize:     proto.Int32(batchSize),
+			Signature:     sig,
+		}
+		signatures = append(signatures, teks)
 	}
 	teksl := export.TEKSignatureList{
-		Signatures: []*export.TEKSignature{teks},
+		Signatures: signatures,
 	}
 	protoBytes, err := proto.Marshal(&teksl)
 	if err != nil {
diff --git a/internal/export/worker.go b/internal/export/worker.go
index 865e853..89f3308 100644
--- a/internal/export/worker.go
+++ b/internal/export/worker.go
@@ -127,6 +127,12 @@ func (s *Server) exportBatch(ctx context.Context, eb *model.ExportBatch, emitInd
 		return fmt.Errorf("ensureMinNumExposures: %w", err)
 	}
 
+	// Load the non-expired signature infos associated with this export batch.
+	sigInfos, err := s.db.LookupSignatureInfos(ctx, eb.SignatureInfoIDs, time.Now())
+	if err != nil {
+		return fmt.Errorf("error loading signature info for batch %d, %w", eb.BatchID, err)
+	}
+
 	// Create the export files.
 	batchSize := len(groups)
 	var objectNames []string
@@ -143,7 +149,14 @@ func (s *Server) exportBatch(ctx context.Context, eb *model.ExportBatch, emitInd
 		}
 
 		// TODO(squee1945): Uploading in parallel (to a point) probably makes better use of network.
-		objectName, err := s.createFile(ctx, exposures, eb, i+1, batchSize)
+		objectName, err := s.createFile(ctx,
+			createFileInfo{
+				exposures:      exposures,
+				exportBatch:    eb,
+				signatureInfos: sigInfos,
+				batchNum:       i + 1,
+				batchSize:      batchSize,
+			})
 		if err != nil {
 			return fmt.Errorf("creating export file %d for batch %d: %w", i+1, eb.BatchID, err)
 		}
@@ -166,25 +179,39 @@ func (s *Server) exportBatch(ctx context.Context, eb *model.ExportBatch, emitInd
 	return nil
 }
 
-func (s *Server) createFile(ctx context.Context, exposures []*model.Exposure, eb *model.ExportBatch, batchNum, batchSize int) (string, error) {
+type createFileInfo struct {
+	exposures      []*model.Exposure
+	exportBatch    *model.ExportBatch
+	signatureInfos []*model.SignatureInfo
+	batchNum       int
+	batchSize      int
+}
+
+func (s *Server) createFile(ctx context.Context, cfi createFileInfo) (string, error) {
 	logger := logging.FromContext(ctx)
-	signer, err := s.env.GetSignerForKey(ctx, eb.SigningKey)
-	if err != nil {
-		return "", fmt.Errorf("unable to get signer for key %v: %w", eb.SigningKey, err)
+
+	var signers []exportSigners
+	for _, si := range cfi.signatureInfos {
+		signer, err := s.env.GetSignerForKey(ctx, si.SigningKey)
+		if err != nil {
+			return "", fmt.Errorf("unable to get signer for key %v: %w", si.SigningKey, err)
+		}
+		signers = append(signers, exportSigners{signatureInfo: si, signer: signer})
 	}
+
 	// Generate exposure key export file.
-	data, err := MarshalExportFile(eb, exposures, batchNum, batchSize, signer, s.config.DefaultKeyID, s.config.DefaultKeyVersion)
+	data, err := MarshalExportFile(cfi.exportBatch, cfi.exposures, cfi.batchNum, cfi.batchSize, signers)
 	if err != nil {
 		return "", fmt.Errorf("marshalling export file: %w", err)
 	}
 
 	// Write to GCS.
-	objectName := exportFilename(eb, batchNum)
-	logger.Infof("Created file %v, signed with key %v", objectName, eb.SigningKey)
+	objectName := exportFilename(cfi.exportBatch, cfi.batchNum)
+	logger.Infof("Created file %v, signed with %v keys", objectName, len(signers))
 	ctx, cancel := context.WithTimeout(ctx, blobOperationTimeout)
 	defer cancel()
-	if err := s.env.Blobstore().CreateObject(ctx, eb.BucketName, objectName, data); err != nil {
-		return "", fmt.Errorf("creating file %s in bucket %s: %w", objectName, eb.BucketName, err)
+	if err := s.env.Blobstore().CreateObject(ctx, cfi.exportBatch.BucketName, objectName, data); err != nil {
+		return "", fmt.Errorf("creating file %s in bucket %s: %w", objectName, cfi.exportBatch.BucketName, err)
 	}
 	return objectName, nil
 }
diff --git a/internal/model/apiconfig/config.go b/internal/model/apiconfig/config.go
deleted file mode 100644
index 99ff97f..0000000
--- a/internal/model/apiconfig/config.go
+++ /dev/null
@@ -1,98 +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 apiconfig
-
-import (
-	"crypto/ecdsa"
-	"time"
-
-	"github.com/google/exposure-notifications-server/internal/android"
-)
-
-const (
-	iosDevice     = "ios"
-	androidDevice = "android"
-)
-
-// APIConfig represents the configuration for a single exposure notification
-// application and their access to and requirements for using the API.
-// DB times of 0 are interpreted to be "unbounded" in that direction.
-type APIConfig struct {
-	AppPackageName    string          `db:"app_package_name"`
-	Platform          string          `db:"platform"`
-	ApkDigestSHA256   []string        `db:"apk_digest"`
-	CTSProfileMatch   bool            `db:"cts_profile_match"`
-	BasicIntegrity    bool            `db:"basic_integrity"`
-	AllowedPastTime   time.Duration   `db:"allowed_past_seconds"`
-	AllowedFutureTime time.Duration   `db:"allowed_future_seconds"`
-	AllowedRegions    map[string]bool `db:"allowed_regions"`
-	AllowAllRegions   bool            `db:"all_regions"`
-
-	// BypassSafetyNet is an internal field for testing that bypasses Android
-	// SafetyNet verification. It is not read from a database and is used for
-	// testing only.
-	BypassSafetyNet bool
-
-	// BypassDeviceCheck is an internal field for testing that bypasses iOS
-	// DeviceCheck verification. It is not read from a database and is used for
-	// testing only.
-	BypassDeviceCheck bool
-
-	// DeviceCheck configuration.
-	DeviceCheckKeyID      string
-	DeviceCheckTeamID     string
-	DeviceCheckPrivateKey *ecdsa.PrivateKey
-}
-
-// New creates a new, empty API config
-func New() *APIConfig {
-	return &APIConfig{AllowedRegions: make(map[string]bool)}
-}
-
-// IsIOS returns true if the platform is equal to `iosDevice`
-func (c *APIConfig) IsIOS() bool {
-	return c.Platform == iosDevice
-}
-
-// IsAndroid returns true if the platform is equal to `android`
-func (c *APIConfig) IsAndroid() bool {
-	return c.Platform == androidDevice
-}
-
-// VerifyOpts returns the Android SafetyNet verification options to be used
-// based on the API config.
-func (c *APIConfig) VerifyOpts(from time.Time, noncer android.Noncer) android.VerifyOpts {
-	digests := make([]string, len(c.ApkDigestSHA256))
-	copy(digests, c.ApkDigestSHA256)
-	rtn := android.VerifyOpts{
-		AppPkgName:      c.AppPackageName,
-		CTSProfileMatch: c.CTSProfileMatch,
-		BasicIntegrity:  c.BasicIntegrity,
-		APKDigest:       digests,
-		Nonce:           noncer,
-	}
-
-	// Calculate the valid time window based on now + config options.
-	if c.AllowedPastTime > 0 {
-		minTime := from.Add(-c.AllowedPastTime)
-		rtn.MinValidTime = minTime
-	}
-	if c.AllowedFutureTime > 0 {
-		maxTime := from.Add(c.AllowedFutureTime)
-		rtn.MaxValidTime = maxTime
-	}
-
-	return rtn
-}
diff --git a/internal/model/authorized_app.go b/internal/model/authorized_app.go
new file mode 100644
index 0000000..e91f1af
--- /dev/null
+++ b/internal/model/authorized_app.go
@@ -0,0 +1,71 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package model
+
+import (
+	"crypto/ecdsa"
+	"time"
+)
+
+const (
+	iosDevice     = "ios"
+	androidDevice = "android"
+)
+
+// AuthorizedApp represents the configuration for a single exposure notification
+// application and their access to and requirements for using the API. DB times
+// of 0 are interpreted to be "unbounded" in that direction.
+type AuthorizedApp struct {
+	AppPackageName  string
+	Platform        string
+	AllowedRegions  map[string]struct{}
+	AllowAllRegions bool
+
+	// SafetyNet configuration.
+	// TODO(sethvargo): Rename these to clarify they are for SafetyNet.
+	AllowedPastTime   time.Duration
+	AllowedFutureTime time.Duration
+	ApkDigestSHA256   []string
+	BasicIntegrity    bool
+	CTSProfileMatch   bool
+
+	// DeviceCheck configuration.
+	DeviceCheckKeyID      string
+	DeviceCheckTeamID     string
+	DeviceCheckPrivateKey *ecdsa.PrivateKey
+}
+
+func NewAuthorizedApp() *AuthorizedApp {
+	return &AuthorizedApp{
+		AllowedRegions: make(map[string]struct{}),
+	}
+}
+
+// IsIOS returns true if the platform is equal to `iosDevice`
+func (c *AuthorizedApp) IsIOS() bool {
+	return c.Platform == iosDevice
+}
+
+// IsAndroid returns true if the platform is equal to `android`
+func (c *AuthorizedApp) IsAndroid() bool {
+	return c.Platform == androidDevice
+}
+
+// IsAllowedRegion returns true if the region is in the list of allowed regions,
+// false otherwise.
+func (c *AuthorizedApp) IsAllowedRegion(s string) bool {
+	_, ok := c.AllowedRegions[s]
+	return ok
+}
diff --git a/internal/config/config.go b/internal/model/authorized_app_test.go
similarity index 69%
rename from internal/config/config.go
rename to internal/model/authorized_app_test.go
index ce8f91d..23aa5e0 100644
--- a/internal/config/config.go
+++ b/internal/model/authorized_app_test.go
@@ -12,15 +12,18 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package config
+package model
 
 import (
-	"context"
-
-	"github.com/google/exposure-notifications-server/internal/model/apiconfig"
+	"testing"
 )
 
-// Provider defines possible APIConfig providers.
-type Provider interface {
-	AppPkgConfig(ctx context.Context, appPkg string) (*apiconfig.APIConfig, error)
+func TestBaseAuthorizedApp(t *testing.T) {
+	cfg := NewAuthorizedApp()
+	if cfg.IsIOS() {
+		t.Errorf("cfg.IoIOS, got true, want false")
+	}
+	if cfg.IsAndroid() {
+		t.Errorf("cfg.IoAndroid, got true, want false")
+	}
 }
diff --git a/internal/model/export.go b/internal/model/export.go
index 086513c..79729da 100644
--- a/internal/model/export.go
+++ b/internal/model/export.go
@@ -26,27 +26,27 @@ var (
 )
 
 type ExportConfig struct {
-	ConfigID     int64         `db:"config_id"`
-	BucketName   string        `db:"bucket_name"`
-	FilenameRoot string        `db:"filename_root"`
-	Period       time.Duration `db:"period_seconds"`
-	Region       string        `db:"region"`
-	From         time.Time     `db:"from_timestamp"`
-	Thru         time.Time     `db:"thru_timestamp"`
-	SigningKey   string        `db:"signing_key"`
+	ConfigID         int64         `db:"config_id"`
+	BucketName       string        `db:"bucket_name"`
+	FilenameRoot     string        `db:"filename_root"`
+	Period           time.Duration `db:"period_seconds"`
+	Region           string        `db:"region"`
+	From             time.Time     `db:"from_timestamp"`
+	Thru             time.Time     `db:"thru_timestamp"`
+	SignatureInfoIDs []int64       `db:"signature_info_ids"`
 }
 
 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"`
-	Region         string    `db:"region" json:"region"`
-	Status         string    `db:"status" json:"status"`
-	LeaseExpires   time.Time `db:"lease_expires" json:"leaseExpires"`
-	SigningKey     string    `db:"signing_key" json:"signingKey"`
+	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"`
+	Region           string    `db:"region" json:"region"`
+	Status           string    `db:"status" json:"status"`
+	LeaseExpires     time.Time `db:"lease_expires" json:"leaseExpires"`
+	SignatureInfoIDs []int64   `db:"signature_info_ids"`
 }
 
 type ExportFile struct {
@@ -58,3 +58,13 @@ type ExportFile struct {
 	BatchSize  int    `db:"batch_size"`
 	Status     string `db:"status"`
 }
+
+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"`
+}
diff --git a/internal/model/exposure.go b/internal/model/exposure.go
index 2151f29..61efe99 100644
--- a/internal/model/exposure.go
+++ b/internal/model/exposure.go
@@ -15,6 +15,8 @@
 package model
 
 import (
+	"crypto/sha256"
+	"encoding/base64"
 	"fmt"
 	"sort"
 	"strings"
@@ -39,7 +41,7 @@ const (
 	MinIntervalCount = 1
 	MaxIntervalCount = 144
 
-	// Self explanitory.
+	// Self explanatory.
 	oneDay = time.Hour * 24
 
 	// interval length
@@ -69,6 +71,50 @@ type Publish struct {
 	Padding                   string        `json:"padding"`
 }
 
+// AndroidNonce returns the Android. This ensures that the data in the request
+// is the same data that was used to create the device attestation.
+func (p *Publish) AndroidNonce() string {
+	// base64 keys are to be lexographically sorted
+	sortedKeys := make([]ExposureKey, len(p.Keys))
+	copy(sortedKeys, p.Keys)
+	sort.Slice(sortedKeys, func(i int, j int) bool {
+		return sortedKeys[i].Key < sortedKeys[j].Key
+	})
+
+	// regions are to be uppercased and then lexographically sorted
+	sortedRegions := make([]string, len(p.Regions))
+	for i, r := range p.Regions {
+		sortedRegions[i] = strings.ToUpper(r)
+	}
+	sort.Strings(sortedRegions)
+
+	keys := make([]string, 0, len(sortedKeys))
+	for _, k := range sortedKeys {
+		keys = append(keys, fmt.Sprintf("%v.%v.%v.%v", k.Key, k.IntervalNumber, k.IntervalCount, k.TransmissionRisk))
+	}
+
+	// The cleartext is a combination of all of the data on the request
+	// in a specific order.
+	//
+	// appPackageName|key[,key]|region[,region]|verificationAuthorityName
+	// Keys are ancoded as
+	//     base64(exposureKey).itnervalNumber.IntervalCount.transmissionRisk
+	// When there is > 1 key, keys are comma separated.
+	// Keys must in sorted order based on the sorting of the base64 exposure key.
+	// Regions are uppercased, sorted, and comma sepreated
+	cleartext :=
+		p.AppPackageName + "|" +
+			strings.Join(keys, ",") + "|" + // where key is b64key.intervalNum.intervalCount
+			strings.Join(sortedRegions, ",") + "|" +
+			p.VerificationPayload
+
+	// Take the sha256 checksum of that data
+	sum := sha256.Sum256([]byte(cleartext))
+
+	// Base64 encode the result.
+	return base64.StdEncoding.EncodeToString(sum[:])
+}
+
 // ExposureKey is the 16 byte key, the start time of the key and the
 // duration of the key. A duration of 0 means 24 hours.
 // - ALL fields are REQUIRED and must meet the constraints below.
@@ -167,7 +213,7 @@ func (t *Transformer) TransformPublish(inData *Publish, batchTime time.Time) ([]
 	// Regions are a multi-value property, uppercase them for storage.
 	// There is no set of "valid" regions overall, but it is defined
 	// elsewhere by what regions an authorized application may write to.
-	// See `apiconfig.APIConfig`
+	// See `authorizedapp.Config`
 	upcaseRegions := make([]string, len(inData.Regions))
 	for i, r := range inData.Regions {
 		upcaseRegions[i] = strings.ToUpper(r)
diff --git a/internal/publish/config.go b/internal/publish/config.go
index ef0643f..910812c 100644
--- a/internal/publish/config.go
+++ b/internal/publish/config.go
@@ -18,33 +18,35 @@ package publish
 import (
 	"time"
 
+	"github.com/google/exposure-notifications-server/internal/authorizedapp"
 	"github.com/google/exposure-notifications-server/internal/database"
-	"github.com/google/exposure-notifications-server/internal/dbapiconfig"
 	"github.com/google/exposure-notifications-server/internal/setup"
 )
 
 // Compile-time check to assert this config matches requirements.
-var _ setup.DBAPIConfigProvider = (*Config)(nil)
+var _ setup.AuthorizedAppConfigProvider = (*Config)(nil)
 var _ setup.DBConfigProvider = (*Config)(nil)
 
 // Config represents the configuration and associated environment variables for
 // the publish components.
 type Config struct {
-	Port                     string        `envconfig:"PORT" default:"8080"`
-	MinRequestDuration       time.Duration `envconfig:"TARGET_REQUEST_DURATION" default:"5s"`
-	MaxKeysOnPublish         int           `envconfig:"MAX_KEYS_ON_PUBLISH" default:"14"`
-	MaxIntervalAge           time.Duration `envconfig:"MAX_INTERVAL_AGE_ON_PUBLISH" default:"360h"`
-	APIConfigRefreshDuration time.Duration `envconfig:"CONFIG_REFRESH_DURATION" default:"5m"`
-	TruncateWindow           time.Duration `envconfig:"TRUNCATE_WINDOW" default:"1h"`
-
-	APIConfigOpts *dbapiconfig.ConfigOpts
-
-	Database *database.Config
+	Port               string        `envconfig:"PORT" default:"8080"`
+	MinRequestDuration time.Duration `envconfig:"TARGET_REQUEST_DURATION" default:"5s"`
+	MaxKeysOnPublish   int           `envconfig:"MAX_KEYS_ON_PUBLISH" default:"14"`
+	MaxIntervalAge     time.Duration `envconfig:"MAX_INTERVAL_AGE_ON_PUBLISH" default:"360h"`
+	TruncateWindow     time.Duration `envconfig:"TRUNCATE_WINDOW" default:"1h"`
+
+	// Bypass flags for local development and testing.
+	BypassSafetyNet   bool `envconfig:"BYPASS_SAFETYNET"`
+	BypassDeviceCheck bool `envconfig:"BYPASS_DEVICECHECK"`
+
+	AuthorizedApp *authorizedapp.Config
+	Database      *database.Config
 }
 
-// API returns the configuration for the DB APIConfig provider.
-func (c *Config) API() *dbapiconfig.ConfigOpts {
-	return c.APIConfigOpts
+// AuthorizedApp returns the configuration for authorizedapp.
+func (c *Config) AuthorizedAppConfig() *authorizedapp.Config {
+	return c.AuthorizedApp
 }
 
 // DB returns the configuration for the databse.
diff --git a/internal/publish/publish.go b/internal/publish/publish.go
index 78d9353..d1cd030 100644
--- a/internal/publish/publish.go
+++ b/internal/publish/publish.go
@@ -21,7 +21,7 @@ import (
 	"net/http"
 	"time"
 
-	"github.com/google/exposure-notifications-server/internal/config"
+	"github.com/google/exposure-notifications-server/internal/authorizedapp"
 	"github.com/google/exposure-notifications-server/internal/database"
 	"github.com/google/exposure-notifications-server/internal/jsonutil"
 	"github.com/google/exposure-notifications-server/internal/logging"
@@ -37,8 +37,8 @@ func NewHandler(ctx context.Context, config *Config, env *serverenv.ServerEnv) (
 	if env.Database() == nil {
 		return nil, fmt.Errorf("missing database in server environment")
 	}
-	if env.APIConfigProvider() == nil {
-		return nil, fmt.Errorf("missing apiconfig provider in server environment")
+	if env.AuthorizedAppProvider() == nil {
+		return nil, fmt.Errorf("missing AuthorizedApp provider in server environment")
 	}
 
 	transformer, err := model.NewTransformer(config.MaxKeysOnPublish, config.MaxIntervalAge, config.TruncateWindow)
@@ -50,20 +50,20 @@ func NewHandler(ctx context.Context, config *Config, env *serverenv.ServerEnv) (
 	logger.Infof("truncate window: %v", config.TruncateWindow)
 
 	return &publishHandler{
-		serverenv:   env,
-		transformer: transformer,
-		config:      config,
-		database:    env.Database(),
-		apiconfig:   env.APIConfigProvider(),
+		serverenv:             env,
+		transformer:           transformer,
+		config:                config,
+		database:              env.Database(),
+		authorizedAppProvider: env.AuthorizedAppProvider(),
 	}, nil
 }
 
 type publishHandler struct {
-	config      *Config
-	serverenv   *serverenv.ServerEnv
-	transformer *model.Transformer
-	database    *database.DB
-	apiconfig   config.Provider
+	config                *Config
+	serverenv             *serverenv.ServerEnv
+	transformer           *model.Transformer
+	database              *database.DB
+	authorizedAppProvider authorizedapp.Provider
 }
 
 // There is a target normalized latency for this function. This is to help prevent
@@ -83,26 +83,30 @@ func (h *publishHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	cfg, err := h.apiconfig.AppPkgConfig(ctx, data.AppPackageName)
+	appConfig, err := h.authorizedAppProvider.AppConfig(ctx, data.AppPackageName)
 	if err != nil {
-		// Log the configuration error, return error to client.
-		// This is retryable, although won't succeed if the error isn't transient.
-		logger.Errorf("no API config, dropping data: %v", err)
-		metrics.WriteInt("publish-error-loading-apiconfig", true, 1)
-		http.Error(w, "internal error", http.StatusInternalServerError)
-		return
-	}
-	if cfg == nil {
-		// configs were loaded, but the request app isn't configured.
-		logger.Errorf("unauthorized application: %v", data.AppPackageName)
-		metrics.WriteInt("publish-app-not-authorized", true, 1)
-		// This returns success to the client.
-		w.WriteHeader(http.StatusOK)
+		// Config loaded, but app with that name isn't registered. This can also
+		// happen if the app was recently registered but the cache hasn't been
+		// refreshed.
+		if err == authorizedapp.AppNotFound {
+			logger.Errorf("unauthorized app: %v", data.AppPackageName)
+			metrics.WriteInt("publish-app-not-authorized", true, 1)
+			// This returns success to the client.
+			w.WriteHeader(http.StatusOK)
+			return
+		}
+
+		// A higher-level configuration error occurred, likely while trying to read
+		// from the database. This is retryable, although won't succeed if the error
+		// isn't transient.
+		logger.Errorf("no AuthorizedApp, dropping data: %v", err)
+		metrics.WriteInt("publish-error-loading-authorizedapp", true, 1)
+		http.Error(w, http.StatusText(http.StatusInternalServerError),
+			http.StatusInternalServerError)
 		return
 	}
 
-	err = verification.VerifyRegions(cfg, data)
-	if err != nil {
+	if err := verification.VerifyRegions(appConfig, data); err != nil {
 		logger.Errorf("verification.VerifyRegions: %v", err)
 		metrics.WriteInt("publish-region-not-authorized", true, 1)
 		// This returns success to the client.
@@ -110,16 +114,22 @@ func (h *publishHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	if cfg.IsIOS() {
-		if err := verification.VerifyDeviceCheck(ctx, cfg, data); err != nil {
+	if appConfig.IsIOS() {
+		if h.config.BypassDeviceCheck {
+			logger.Errorf("bypassing DeviceCheck for %v", data.AppPackageName)
+			metrics.WriteInt("publish-devicecheck-bypass", true, 1)
+		} else if err := verification.VerifyDeviceCheck(ctx, appConfig, data); err != nil {
 			logger.Errorf("unable to verify devicecheck payload: %v", err)
 			metrics.WriteInt("publish-devicecheck-invalid", true, 1)
 			// Return success to the client.
 			w.WriteHeader(http.StatusOK)
 			return
 		}
-	} else if cfg.IsAndroid() {
-		if err := verification.VerifySafetyNet(ctx, time.Now(), cfg, data); err != nil {
+	} else if appConfig.IsAndroid() {
+		if h.config.BypassSafetyNet {
+			logger.Errorf("bypassing SafetyNet for %v", data.AppPackageName)
+			metrics.WriteInt("publish-safetynet-bypass", true, 1)
+		} else if err := verification.VerifySafetyNet(ctx, time.Now(), appConfig, data); err != nil {
 			logger.Errorf("unable to verify safetynet payload: %v", err)
 			metrics.WriteInt("publish-safetnet-invalid", true, 1)
 			// Return success to the client.
@@ -127,8 +137,9 @@ func (h *publishHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 			return
 		}
 	} else {
-		logger.Errorf("invalid API configuration for AppPkg: %v, invalid platform", data.AppPackageName)
-		metrics.WriteInt("publish-apiconfig-missing-platform", true, 1)
+		logger.Errorf("invalid AuthorizedApp config %v: invalid platform %v",
+			data.AppPackageName, data.Platform)
+		metrics.WriteInt("publish-authorizedapp-missing-platform", true, 1)
 		// This returns success to the client.
 		w.WriteHeader(http.StatusOK)
 		return
@@ -149,7 +160,8 @@ func (h *publishHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		logger.Errorf("error writing exposure record: %v", err)
 		metrics.WriteInt("publish-db-write-error", true, 1)
 		// This is retryable at the client - database error at the server.
-		http.Error(w, "internal processing error", http.StatusInternalServerError)
+		http.Error(w, http.StatusText(http.StatusInternalServerError),
+			http.StatusInternalServerError)
 		return
 	}
 	metrics.WriteInt("publish-exposures-written", true, len(exposures))
diff --git a/internal/secrets/azure_keyvault.go b/internal/secrets/azure_keyvault.go
new file mode 100644
index 0000000..1307ea0
--- /dev/null
+++ b/internal/secrets/azure_keyvault.go
@@ -0,0 +1,84 @@
+// 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 secrets
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	"github.com/Azure/azure-sdk-for-go/profiles/latest/keyvault/keyvault"
+	"github.com/Azure/azure-sdk-for-go/services/keyvault/auth"
+)
+
+// Compile-time check to verify implements interface.
+var _ SecretManager = (*AzureKeyVault)(nil)
+
+// AzureKeyVault implements SecretManager.
+type AzureKeyVault struct {
+	client *keyvault.BaseClient
+}
+
+// NewAzureKeyVault creates a new KeyVault that can interact fetch secrets.
+func NewAzureKeyVault(ctx context.Context) (SecretManager, error) {
+	authorizer, err := auth.NewAuthorizerFromEnvironment()
+	if err != nil {
+		return nil, fmt.Errorf("secrets.NewAzureKeyVault: auth: %w", err)
+	}
+
+	client := keyvault.New()
+	client.Authorizer = authorizer
+
+	sm := &AzureKeyVault{
+		client: &client,
+	}
+
+	return sm, nil
+}
+
+// GetSecretValue implements the SecretManager interface. Secrets are specified
+// in the format:
+//
+//     AZURE_KEY_VAULT_NAME/SECRET_NAME/SECRET_VERSION
+//
+// For example:
+//
+//     my-company-vault/api-key/1
+//
+// If the secret version is omitted, the latest version is used.
+func (kv *AzureKeyVault) GetSecretValue(ctx context.Context, name string) (string, error) {
+	// Extract vault, secret, and version.
+	var vaultName, secretName, version string
+	parts := strings.SplitN(name, "/", 3)
+	switch len(parts) {
+	case 0, 1:
+		return "", fmt.Errorf("%v is not a valid secret ref", name)
+	case 2:
+		vaultName, secretName, version = parts[0], parts[1], ""
+	case 3:
+		vaultName, secretName, version = parts[0], parts[1], parts[2]
+	}
+
+	// Lookup in KeyVault
+	vaultURL := fmt.Sprintf("https://%s.vault.azure.net", vaultName)
+	result, err := kv.client.GetSecret(ctx, vaultURL, secretName, version)
+	if err != nil {
+		return "", fmt.Errorf("failed to access secret %v: %w", name, err)
+	}
+	if result.Value == nil {
+		return "", fmt.Errorf("found secret %v, but value was nil", name)
+	}
+	return string(*result.Value), nil
+}
diff --git a/internal/secrets/cacher.go b/internal/secrets/cacher.go
new file mode 100644
index 0000000..eec019d
--- /dev/null
+++ b/internal/secrets/cacher.go
@@ -0,0 +1,91 @@
+// 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 secrets
+
+import (
+	"context"
+	"fmt"
+	"sync"
+	"time"
+
+	"github.com/google/exposure-notifications-server/internal/logging"
+)
+
+// Compile-time check to verify implements interface.
+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
+}
+
+type cachedItem struct {
+	value    string
+	cachedAt time.Time
+}
+
+// NewCacher creates a new secret manager that caches results for the given ttl.
+func NewCacher(ctx context.Context, f SecretManagerFunc, ttl time.Duration) (SecretManager, error) {
+	sm, err := f(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("cacher: %w", err)
+	}
+
+	return WrapCacher(ctx, sm, ttl)
+}
+
+// WrapCacher wraps an existing SecretManager with caching.
+func WrapCacher(ctx context.Context, sm SecretManager, ttl time.Duration) (SecretManager, error) {
+	return &Cacher{
+		sm:    sm,
+		ttl:   ttl,
+		cache: make(map[string]*cachedItem),
+	}, 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
+	}
+
+	// Delegate lookup to parent sm.
+	plaintext, err := sm.sm.GetSecretValue(ctx, name)
+	if err != nil {
+		return "", err
+	}
+
+	// Cache value
+	sm.cache[name] = &cachedItem{
+		value:    plaintext,
+		cachedAt: time.Now(),
+	}
+
+	return plaintext, nil
+}
diff --git a/internal/secrets/gcp_secret_manager.go b/internal/secrets/gcp_secret_manager.go
index 1e6632e..2e969a6 100644
--- a/internal/secrets/gcp_secret_manager.go
+++ b/internal/secrets/gcp_secret_manager.go
@@ -12,102 +12,43 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-// Package secrets defines a minimum abstract interface for a secret manager.
-// Allows for a different implementation to be bound within the servernv.ServeEnv
 package secrets
 
 import (
 	"context"
 	"fmt"
-	"sync"
-	"time"
-
-	"github.com/google/exposure-notifications-server/internal/logging"
 
 	secretmanager "cloud.google.com/go/secretmanager/apiv1"
 	secretmanagerpb "google.golang.org/genproto/googleapis/cloud/secretmanager/v1"
 )
 
-const (
-	// defaultCacheDuration is how long to cache a secret.
-	defaultCacheDuration = 5 * time.Minute
-)
+// Compile-time check to verify implements interface.
+var _ SecretManager = (*GCPSecretManager)(nil)
 
+// GCPSecretManager implements SecretManager.
 type GCPSecretManager struct {
 	client *secretmanager.Client
-
-	cache         map[string]*item
-	cacheDuration time.Duration
-	cacheMutex    sync.Mutex
-}
-
-type item struct {
-	value     string
-	expiresAt int64
-}
-
-// Option is an option to the setup.
-type Option func(*GCPSecretManager) *GCPSecretManager
-
-// WithCacheDuration creates an Option to install a specific secret manager to use.
-func WithCacheDuration(d time.Duration) Option {
-	return func(s *GCPSecretManager) *GCPSecretManager {
-		s.cacheDuration = d
-		return s
-	}
 }
 
-func NewGCPSecretManager(ctx context.Context, opts ...Option) (SecretManager, error) {
+// NewGCPSecretManager creates a new secret manager for GCP.
+func NewGCPSecretManager(ctx context.Context) (SecretManager, error) {
 	client, err := secretmanager.NewClient(ctx)
 	if err != nil {
 		return nil, fmt.Errorf("secretmanager.NewClient: %w", err)
 	}
 
 	sm := &GCPSecretManager{
-		client:        client,
-		cache:         make(map[string]*item),
-		cacheDuration: defaultCacheDuration,
-	}
-
-	for _, f := range opts {
-		sm = f(sm)
+		client: client,
 	}
 
 	return sm, nil
 }
 
-// GetSecretValue implements the SecretManager interface.
+// GetSecretValue implements the SecretManager interface. Secret names should be
+// of the format:
+//
+//     projects/my-project/secrets/my-secret/versions/123
 func (sm *GCPSecretManager) 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 && i.expiresAt <= time.Now().UnixNano() {
-		logger.Debugf("loaded secret %v from cache", name)
-		return i.value, nil
-	}
-
-	// Lookup in secret manager
-	plaintext, err := sm.accessSecret(ctx, name)
-	if err != nil {
-		return "", err
-	}
-
-	// Cache value
-	sm.cache[name] = &item{
-		value:     plaintext,
-		expiresAt: time.Now().Add(sm.cacheDuration).UnixNano(),
-	}
-
-	return plaintext, nil
-}
-
-// accessSecret accesses the secret from secret manager.
-func (sm *GCPSecretManager) accessSecret(ctx context.Context, name string) (string, error) {
-	// Call the API.
 	result, err := sm.client.AccessSecretVersion(ctx, &secretmanagerpb.AccessSecretVersionRequest{
 		Name: name,
 	})
diff --git a/internal/secrets/hashicorp_vault.go b/internal/secrets/hashicorp_vault.go
new file mode 100644
index 0000000..573a398
--- /dev/null
+++ b/internal/secrets/hashicorp_vault.go
@@ -0,0 +1,86 @@
+// 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 secrets
+
+import (
+	"context"
+	"fmt"
+	"strconv"
+
+	vaultapi "github.com/hashicorp/vault/api"
+)
+
+// Compile-time check to verify implements interface.
+var _ SecretManager = (*HashiCorpVault)(nil)
+
+type HashiCorpVault struct {
+	client *vaultapi.Client
+}
+
+// NewHashiCorpVault fetches secrets from HashiCorp Vault.
+func NewHashiCorpVault(ctx context.Context) (SecretManager, error) {
+	client, err := vaultapi.NewClient(nil)
+	if err != nil {
+		return nil, fmt.Errorf("secrets.NewHashiCorpVault: client: %w", err)
+	}
+
+	sm := &HashiCorpVault{
+		client: client,
+	}
+
+	return sm, nil
+}
+
+// GetSecretValue implements the SecretManager interface. Secrets are specified
+// as the path to the secret in Vault. Secrets are expected to have the string
+// value for the secret in a key named "value".
+//
+// For example:
+//
+//     /secret/data/my-secret?version=5 #=> { "value": "dajkfl32ip2" }
+//
+// Note: this technically allows you to fetch dynamic secrets, but this library
+// makes no attempt at renewing leases!
+func (kv *HashiCorpVault) GetSecretValue(ctx context.Context, name string) (string, error) {
+	secret, err := kv.client.Logical().Read(name)
+	if err != nil {
+		return "", fmt.Errorf("failed to access secret %v: %w", name, err)
+	}
+	if secret == nil || secret.Data == nil {
+		return "", fmt.Errorf("found secret %v, but value was nil", name)
+	}
+
+	// Check if the "value" key is present.
+	raw, ok := secret.Data["value"]
+	if !ok {
+		return "", fmt.Errorf("found secret %v, does not have 'value' key", name)
+	}
+
+	// Vault values are map[string]interface{}, so coerce to a string.
+	switch typ := raw.(type) {
+	case string:
+		return typ, nil
+	case []byte:
+		return string(typ), nil
+	case bool:
+		return strconv.FormatBool(typ), nil
+	case int, int8, int16, int32, int64:
+		return fmt.Sprintf("%d", typ), nil
+	case uint, uint8, uint16, uint32, uint64:
+		return fmt.Sprintf("%d", typ), nil
+	default:
+		return "", fmt.Errorf("found secret %v, but is of unknown type %T", name, typ)
+	}
+}
diff --git a/internal/secrets/secrets.go b/internal/secrets/secrets.go
index c279592..8ea4d51 100644
--- a/internal/secrets/secrets.go
+++ b/internal/secrets/secrets.go
@@ -23,3 +23,6 @@ import "context"
 type SecretManager interface {
 	GetSecretValue(ctx context.Context, name string) (string, error)
 }
+
+// SecretManagerFunc is a func that returns a secret manager or error.
+type SecretManagerFunc func(ctx context.Context) (SecretManager, error)
diff --git a/internal/serverenv/env.go b/internal/serverenv/env.go
index 41719ac..70c4d39 100644
--- a/internal/serverenv/env.go
+++ b/internal/serverenv/env.go
@@ -20,7 +20,7 @@ import (
 	"crypto"
 	"fmt"
 
-	"github.com/google/exposure-notifications-server/internal/config"
+	"github.com/google/exposure-notifications-server/internal/authorizedapp"
 	"github.com/google/exposure-notifications-server/internal/database"
 	"github.com/google/exposure-notifications-server/internal/metrics"
 	"github.com/google/exposure-notifications-server/internal/secrets"
@@ -33,12 +33,12 @@ type ExporterFunc func(context.Context) metrics.Exporter
 
 // ServerEnv represents latent environment configuration for servers in this application.
 type ServerEnv struct {
-	secretManager     secrets.SecretManager
-	keyManager        signing.KeyManager
-	blobstore         storage.Blobstore
-	exporter          metrics.ExporterFromContext
-	apiConfigProvider config.Provider
-	database          *database.DB
+	authorizedAppProvider authorizedapp.Provider
+	blobstore             storage.Blobstore
+	database              *database.DB
+	exporter              metrics.ExporterFromContext
+	keyManager            signing.KeyManager
+	secretManager         secrets.SecretManager
 }
 
 // Option defines function types to modify the ServerEnv on creation.
@@ -68,10 +68,10 @@ func WithDatabase(db *database.DB) Option {
 	}
 }
 
-// WithAPIConfigProvider installs a provider of APIConfig.
-func WithAPIConfigProvider(p config.Provider) Option {
+// WithAuthorizedAppProvider installs a provider for an authorized app.
+func WithAuthorizedAppProvider(p authorizedapp.Provider) Option {
 	return func(s *ServerEnv) *ServerEnv {
-		s.apiConfigProvider = p
+		s.authorizedAppProvider = p
 		return s
 	}
 }
@@ -120,8 +120,8 @@ func (s *ServerEnv) Blobstore() storage.Blobstore {
 	return s.blobstore
 }
 
-func (s *ServerEnv) APIConfigProvider() config.Provider {
-	return s.apiConfigProvider
+func (s *ServerEnv) AuthorizedAppProvider() authorizedapp.Provider {
+	return s.authorizedAppProvider
 }
 
 func (s *ServerEnv) Database() *database.DB {
diff --git a/internal/setup/setup.go b/internal/setup/setup.go
index b9ec56a..8f9e0b2 100644
--- a/internal/setup/setup.go
+++ b/internal/setup/setup.go
@@ -17,9 +17,10 @@ package setup
 import (
 	"context"
 	"fmt"
+	"time"
 
+	"github.com/google/exposure-notifications-server/internal/authorizedapp"
 	"github.com/google/exposure-notifications-server/internal/database"
-	"github.com/google/exposure-notifications-server/internal/dbapiconfig"
 	"github.com/google/exposure-notifications-server/internal/envconfig"
 	"github.com/google/exposure-notifications-server/internal/metrics"
 	"github.com/google/exposure-notifications-server/internal/secrets"
@@ -34,10 +35,10 @@ type DBConfigProvider interface {
 	DB() *database.Config
 }
 
-// DBAPIConfigProvider signals that the config provided knows how to configure
-// and requires a DB backed APIConfigProvider installed.
-type DBAPIConfigProvider interface {
-	API() *dbapiconfig.ConfigOpts
+// AuthorizedAppConfigProvider signals that the config provided knows how to
+// configure authorized apps.
+type AuthorizedAppConfigProvider interface {
+	AuthorizedAppConfig() *authorizedapp.Config
 }
 
 // KeyManagerProvider is a marker interface indicating the KeyManagerProvider should be installed.
@@ -57,7 +58,8 @@ type Defer func()
 func Setup(ctx context.Context, config DBConfigProvider) (*serverenv.ServerEnv, Defer, error) {
 	// Can be changed with a different secret manager interface.
 	// TODO(mikehelmick): Make this extensible to other providers.
-	sm, err := secrets.NewGCPSecretManager(ctx)
+	// TODO(sethvargo): Make TTL configurable.
+	sm, err := secrets.NewCacher(ctx, secrets.NewGCPSecretManager, 5*time.Minute)
 	if err != nil {
 		return nil, nil, fmt.Errorf("unable to connect to secret manager: %v", err)
 	}
@@ -89,20 +91,22 @@ func Setup(ctx context.Context, config DBConfigProvider) (*serverenv.ServerEnv,
 		opts = append(opts, serverenv.WithBlobStorage(storage))
 	}
 
+	// Setup the database connection.
 	db, err := database.NewFromEnv(ctx, config.DB())
 	if err != nil {
 		return nil, nil, fmt.Errorf("unable to connect to database: %v", err)
 	}
 	opts = append(opts, serverenv.WithDatabase(db))
 
-	if apicfg, ok := config.(DBAPIConfigProvider); ok {
-		cfgProvider, err := dbapiconfig.NewConfigProvider(db, sm, apicfg.API())
+	// AuthorizedApp must come after database setup due to the dependency.
+	if typ, ok := config.(AuthorizedAppConfigProvider); ok {
+		provider, err := authorizedapp.NewDatabaseProvider(ctx, db, typ.AuthorizedAppConfig(), authorizedapp.WithSecretManager(sm))
 		if err != nil {
-			// APIConfig must come after DB due to dependency, ensure connection is closed
+			// Ensure the database is closed on an error.
 			defer db.Close(ctx)
-			return nil, nil, fmt.Errorf("unable to create APIConfig provider: %v", err)
+			return nil, nil, fmt.Errorf("unable to create AuthorizedApp provider: %v", err)
 		}
-		opts = append(opts, serverenv.WithAPIConfigProvider(cfgProvider))
+		opts = append(opts, serverenv.WithAuthorizedAppProvider(provider))
 	}
 
 	return serverenv.New(ctx, opts...), func() { db.Close(ctx) }, nil
diff --git a/internal/verification/verify.go b/internal/verification/verify.go
index a080705..9206070 100644
--- a/internal/verification/verify.go
+++ b/internal/verification/verify.go
@@ -21,9 +21,7 @@ import (
 
 	"github.com/google/exposure-notifications-server/internal/android"
 	"github.com/google/exposure-notifications-server/internal/ios"
-	"github.com/google/exposure-notifications-server/internal/logging"
 	"github.com/google/exposure-notifications-server/internal/model"
-	"github.com/google/exposure-notifications-server/internal/model/apiconfig"
 )
 
 var (
@@ -33,7 +31,7 @@ var (
 
 // VerifyRegions checks the request regions against the regions allowed by
 // the configuration for the application.
-func VerifyRegions(cfg *apiconfig.APIConfig, data *model.Publish) error {
+func VerifyRegions(cfg *model.AuthorizedApp, data *model.Publish) error {
 	if cfg == nil {
 		return fmt.Errorf("no allowed regions configured")
 	}
@@ -43,7 +41,7 @@ func VerifyRegions(cfg *apiconfig.APIConfig, data *model.Publish) error {
 	}
 
 	for _, r := range data.Regions {
-		if v, ok := cfg.AllowedRegions[r]; !ok || !v {
+		if !cfg.IsAllowedRegion(r) {
 			return fmt.Errorf("application '%v' tried to write unauthorized region: '%v'", cfg.AppPackageName, r)
 		}
 	}
@@ -54,21 +52,13 @@ func VerifyRegions(cfg *apiconfig.APIConfig, data *model.Publish) error {
 
 // VerifySafetyNet verifies the Android SafetyNet device attestation against the
 // allowed configuration for the application.
-func VerifySafetyNet(ctx context.Context, requestTime time.Time, cfg *apiconfig.APIConfig, data *model.Publish) error {
-	logger := logging.FromContext(ctx)
-
+func VerifySafetyNet(ctx context.Context, requestTime time.Time, cfg *model.AuthorizedApp, publish *model.Publish) error {
 	if cfg == nil {
-		logger.Errorf("safetynet enabled, but no config for application: %v", data.AppPackageName)
-		// TODO(mikehelmick): Should this be a default configuration?
-		return fmt.Errorf("cannot enforce safetynet, no application config")
+		return fmt.Errorf("cannot enforce SafetyNet, missing config")
 	}
 
-	opts := cfg.VerifyOpts(requestTime, android.NewNonce(data))
-	if err := androidValidateAttestation(ctx, data.DeviceVerificationPayload, opts); err != nil {
-		if cfg.BypassSafetyNet {
-			logger.Errorf("bypassing safetynet verification for: '%v'", data.AppPackageName)
-			return nil
-		}
+	opts := android.VerifyOptsFor(cfg, requestTime, publish.AndroidNonce())
+	if err := androidValidateAttestation(ctx, publish.DeviceVerificationPayload, opts); err != nil {
 		return fmt.Errorf("android.ValidateAttestation: %w", err)
 	}
 
@@ -76,11 +66,9 @@ func VerifySafetyNet(ctx context.Context, requestTime time.Time, cfg *apiconfig.
 }
 
 // VerifyDeviceCheck verifies an iOS DeviceCheck token against the Apple API.
-func VerifyDeviceCheck(ctx context.Context, cfg *apiconfig.APIConfig, data *model.Publish) error {
-	logger := logging.FromContext(ctx)
-
+func VerifyDeviceCheck(ctx context.Context, cfg *model.AuthorizedApp, data *model.Publish) error {
 	if cfg == nil {
-		return fmt.Errorf("cannot enforce devicecheck, missing config")
+		return fmt.Errorf("cannot enforce DeviceCheck, missing config")
 	}
 
 	opts := &ios.VerifyOpts{
@@ -90,10 +78,6 @@ func VerifyDeviceCheck(ctx context.Context, cfg *apiconfig.APIConfig, data *mode
 	}
 
 	if err := iosValidateDeviceToken(ctx, data.DeviceVerificationPayload, opts); err != nil {
-		if cfg.BypassDeviceCheck {
-			logger.Errorf("devicecheck failed, but bypass enabled for app: '%v', failure: '%v'", data.AppPackageName, err)
-			return nil
-		}
 		return fmt.Errorf("ios.ValidateDeviceToken: %w", err)
 	}
 
diff --git a/migrations/000007_drop_bypass_safetynet.down.sql b/migrations/000007_drop_bypass_safetynet.down.sql
index a781861..a90a7f4 100644
--- a/migrations/000007_drop_bypass_safetynet.down.sql
+++ b/migrations/000007_drop_bypass_safetynet.down.sql
@@ -15,6 +15,6 @@
 BEGIN;
 
 ALTER TABLE APIConfig
-  ADD COLUMN bypass_safetynet bool NOT NULL;
+  ADD COLUMN bypass_safetynet bool DEFAULT false;
 
 END;
diff --git a/migrations/000018_idx_app_package_name.down.sql b/migrations/000018_idx_app_package_name.down.sql
new file mode 100644
index 0000000..154b5a5
--- /dev/null
+++ b/migrations/000018_idx_app_package_name.down.sql
@@ -0,0 +1,19 @@
+-- Copyright 2020 Google LLC
+--
+-- Licensed under the Apache License, Version 2.0 (the "License");
+-- you may not use this file except in compliance with the License.
+-- You may obtain a copy of the License at
+--
+--      http://www.apache.org/licenses/LICENSE-2.0
+--
+-- Unless required by applicable law or agreed to in writing, software
+-- distributed under the License is distributed on an "AS IS" BASIS,
+-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+-- See the License for the specific language governing permissions and
+-- limitations under the License.
+
+BEGIN;
+
+DROP INDEX apiconfig_app_package_name_idx;
+
+END;
diff --git a/migrations/000018_idx_app_package_name.up.sql b/migrations/000018_idx_app_package_name.up.sql
new file mode 100644
index 0000000..7a756ad
--- /dev/null
+++ b/migrations/000018_idx_app_package_name.up.sql
@@ -0,0 +1,19 @@
+-- Copyright 2020 Google LLC
+--
+-- Licensed under the Apache License, Version 2.0 (the "License");
+-- you may not use this file except in compliance with the License.
+-- You may obtain a copy of the License at
+--
+--      http://www.apache.org/licenses/LICENSE-2.0
+--
+-- Unless required by applicable law or agreed to in writing, software
+-- distributed under the License is distributed on an "AS IS" BASIS,
+-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+-- See the License for the specific language governing permissions and
+-- limitations under the License.
+
+BEGIN;
+
+CREATE INDEX apiconfig_app_package_name_idx ON APIConfig(app_package_name);
+
+END;
diff --git a/migrations/000019_app_to_exportconfig.down.sql b/migrations/000019_app_to_exportconfig.down.sql
new file mode 100644
index 0000000..598e50f
--- /dev/null
+++ b/migrations/000019_app_to_exportconfig.down.sql
@@ -0,0 +1,27 @@
+-- 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 ExportBatch
+  DROP COLUMN signature_info_ids,
+  ADD COLUMN signing_key VARCHAR(500);
+
+ALTER TABLE ExportConfig
+  DROP COLUMN signature_info_ids,
+  ADD COLUMN signing_key VARCHAR(500);
+
+DROP TABLE SignatureInfo;
+
+END;
diff --git a/migrations/000019_app_to_exportconfig.up.sql b/migrations/000019_app_to_exportconfig.up.sql
new file mode 100644
index 0000000..e10bb1d
--- /dev/null
+++ b/migrations/000019_app_to_exportconfig.up.sql
@@ -0,0 +1,39 @@
+-- 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;
+
+-- This migration is destructive to any existing data.
+
+CREATE TABLE SignatureInfo (
+  id SERIAL PRIMARY KEY,
+  signing_key VARCHAR(500) NOT NULL,
+  app_package_name VARCHAR(1000) NOT NULL DEFAULT '',
+  bundle_id VARCHAR(1000) NOT NULL DEFAULT '',
+  signing_key_version VARCHAR(100) NOT NULL DEFAULT '',
+  signing_key_id VARCHAR(50) NOT NULL DEFAULT '',
+	thru_timestamp TIMESTAMPTZ
+);
+
+ALTER TABLE SignatureInfo SET (autovacuum_enabled = true);
+
+ALTER TABLE ExportConfig
+  ADD COLUMN signature_info_ids INT [],
+  DROP COLUMN signing_key;
+
+ALTER TABLE ExportBatch
+  ADD COLUMN signature_info_ids INT [],
+  DROP COLUMN signing_key;
+
+END;
diff --git a/migrations/000020_rename_apiconfig_to_authorized_app.down.sql b/migrations/000020_rename_apiconfig_to_authorized_app.down.sql
new file mode 100644
index 0000000..d7ef654
--- /dev/null
+++ b/migrations/000020_rename_apiconfig_to_authorized_app.down.sql
@@ -0,0 +1,22 @@
+-- Copyright 2020 Google LLC
+--
+-- Licensed under the Apache License, Version 2.0 (the "License");
+-- you may not use this file except in compliance with the License.
+-- You may obtain a copy of the License at
+--
+--      http://www.apache.org/licenses/LICENSE-2.0
+--
+-- Unless required by applicable law or agreed to in writing, software
+-- distributed under the License is distributed on an "AS IS" BASIS,
+-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+-- See the License for the specific language governing permissions and
+-- limitations under the License.
+
+BEGIN;
+
+ALTER TABLE AuthorizedApp RENAME TO APIConfig;
+ALTER INDEX authorized_app_pkey RENAME TO apiconfig_pkey;
+ALTER INDEX authorized_app_app_package_name_idx RENAME TO apiconfig_app_package_name_idx;
+
+
+END;
diff --git a/migrations/000020_rename_apiconfig_to_authorized_app.up.sql b/migrations/000020_rename_apiconfig_to_authorized_app.up.sql
new file mode 100644
index 0000000..7147287
--- /dev/null
+++ b/migrations/000020_rename_apiconfig_to_authorized_app.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 APIConfig RENAME TO AuthorizedApp;
+ALTER INDEX apiconfig_pkey RENAME TO authorized_app_pkey;
+ALTER INDEX apiconfig_app_package_name_idx RENAME TO authorized_app_app_package_name_idx;
+
+END;
diff --git a/tools/export-config/main.go b/tools/export-config/main.go
index e8dbb10..d1c1f11 100644
--- a/tools/export-config/main.go
+++ b/tools/export-config/main.go
@@ -28,13 +28,17 @@ import (
 )
 
 var (
-	bucketName    = flag.String("bucket-name", "", "The bucket name to store the export file.")
-	filenameRoot  = flag.String("filename-root", "", "The root filename for the export file.")
-	period        = flag.Duration("period", 24*time.Hour, "The frequency with which to create export files.")
-	region        = flag.String("region", "", "The region for the export batches/files.")
-	fromTimestamp = flag.String("from-timestamp", "", "The timestamp (RFC3339) when this config becomes active.")
-	thruTimestamp = flag.String("thru-timestamp", "", "The timestamp (RFC3339) when this config ends.")
-	signingKey    = flag.String("signing-key", "", "The KMS resource ID to use for signing batches.")
+	bucketName        = flag.String("bucket-name", "", "The bucket name to store the export file.")
+	filenameRoot      = flag.String("filename-root", "", "The root filename for the export file.")
+	period            = flag.Duration("period", 24*time.Hour, "The frequency with which to create export files.")
+	region            = flag.String("region", "", "The region for the export batches/files.")
+	fromTimestamp     = flag.String("from-timestamp", "", "The timestamp (RFC3339) when this config becomes active.")
+	thruTimestamp     = flag.String("thru-timestamp", "", "The timestamp (RFC3339) when this config ends.")
+	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 Packge ID to put in export headers")
+	bundleID          = flag.String("bundle-id", "", "The BundleID to put in export headers")
 )
 
 func main() {
@@ -86,16 +90,26 @@ func main() {
 	}
 	defer db.Close(ctx)
 
-	ec := model.ExportConfig{
-		BucketName:   *bucketName,
-		FilenameRoot: *filenameRoot,
-		Period:       *period,
-		Region:       *region,
-		From:         fromTime,
-		Thru:         thruTime,
-		SigningKey:   *signingKey,
+	si := model.SignatureInfo{
+		SigningKey:        *signingKey,
+		AppPackageName:    *appPkgID,
+		BundleID:          *bundleID,
+		SigningKeyVersion: *signingKeyVersion,
+		SigningKeyID:      *signingKeyID,
+	}
+	if err := db.AddSignatureInfo(ctx, &si); err != nil {
+		log.Fatalf("AddSignatureInfo: %v", err)
 	}
 
+	ec := model.ExportConfig{
+		BucketName:       *bucketName,
+		FilenameRoot:     *filenameRoot,
+		Period:           *period,
+		Region:           *region,
+		From:             fromTime,
+		Thru:             thruTime,
+		SignatureInfoIDs: []int64{si.ID},
+	}
 	if err := db.AddExportConfig(ctx, &ec); err != nil {
 		log.Fatalf("Failure: %v", err)
 	}


Test patch:
diff --git a/internal/android/nonce_test.go b/internal/android/nonce_test.go
deleted file mode 100644
index aab99f0..0000000
--- a/internal/android/nonce_test.go
+++ /dev/null
@@ -1,94 +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 android
-
-import (
-	"testing"
-
-	"github.com/google/exposure-notifications-server/internal/model"
-)
-
-// Data from this test was generated by the Android reference application.
-func TestGetNonce(t *testing.T) {
-	testData := []struct {
-		Data     model.Publish
-		Expected string
-	}{
-		{
-			Data: model.Publish{
-				Keys: []model.ExposureKey{
-					{
-						Key:              "x21Goi8X9m/glOZ0+wz8fA",
-						IntervalNumber:   263123,
-						IntervalCount:    144,
-						TransmissionRisk: 6,
-					},
-					{
-						Key:              "2mvFSmRsFmJR5r07dxGSjg",
-						IntervalNumber:   263267,
-						IntervalCount:    144,
-						TransmissionRisk: 7,
-					},
-					{
-						Key:              "6bAd3dv7p+VEuaJVkVItaQ",
-						IntervalNumber:   263411,
-						IntervalCount:    27,
-						TransmissionRisk: 7,
-					},
-				},
-				Regions:             []string{"GB", "US"},
-				AppPackageName:      appPackage,
-				VerificationPayload: "QRTH-ROWO-LOLO-FOOB",
-			},
-			Expected: "0kbOC1LIwDc+DQnLOFcIbnxqfKNg58b7WA3vREdd7ZQ=",
-		},
-		{
-			Data: model.Publish{
-				Keys: []model.ExposureKey{
-					{
-						Key:              "zdCW5HrOKbirxmQVc0L/eA",
-						IntervalNumber:   263123,
-						IntervalCount:    144,
-						TransmissionRisk: 8,
-					},
-					{
-						Key:              "t+k51ifogJo9jq3GH9LWGQ",
-						IntervalNumber:   263267,
-						IntervalCount:    144,
-						TransmissionRisk: 8,
-					},
-					{
-						Key:              "3uXRrSlcv1+OMI3oFtdaUw",
-						IntervalNumber:   263411,
-						IntervalCount:    27,
-						TransmissionRisk: 8,
-					},
-				},
-				Regions:             []string{"gB", "us"},
-				AppPackageName:      appPackage,
-				VerificationPayload: "BREA-KMEO-FFAP-IECE",
-			},
-			Expected: "KYCIywFRTwXCnYCF4TTFaH//iMb/fBQENqIhtzrJPxA=",
-		},
-	}
-
-	for _, input := range testData {
-		data := NewNonce(&input.Data)
-		nonce := data.Nonce()
-		if nonce != input.Expected {
-			t.Errorf("getNonce: got '%v', want '%v'", nonce, input.Expected)
-		}
-	}
-}
diff --git a/internal/android/safetynet_test.go b/internal/android/safetynet_test.go
index ac35e0c..3ffdb70 100644
--- a/internal/android/safetynet_test.go
+++ b/internal/android/safetynet_test.go
@@ -21,18 +21,13 @@ import (
 
 	"github.com/google/exposure-notifications-server/internal/base64util"
 	"github.com/google/exposure-notifications-server/internal/model"
+	"github.com/google/go-cmp/cmp"
 )
 
 const (
 	appPackage = "com.google.android.apps.exposurenotification"
 )
 
-type emptyNonce struct{}
-
-func (e *emptyNonce) Nonce() string {
-	return ""
-}
-
 var (
 	// **This is not a secret value.**
 	// This is a SafetyNet Attestation payload, https://developer.android.com/training/safetynet/attestation#overview
@@ -61,7 +56,7 @@ func TestVerifyAttestation(t *testing.T) {
 		t.Fatalf("error verifying attestation %v", err)
 	}
 
-	expectedNonce := NewNonce(publish).Nonce()
+	expectedNonce := publish.AndroidNonce()
 	actualBytes, err := base64util.DecodeString(claims["nonce"].(string))
 	actualNonce := string(actualBytes)
 	if err != nil {
@@ -73,8 +68,7 @@ func TestVerifyAttestation(t *testing.T) {
 }
 
 func TestValidateAttestation(t *testing.T) {
-	nonce := NewNonce(publish)
-
+	nonce := publish.AndroidNonce()
 	apkDigest := "jqmYEqi9qUvpUe11qMf3v2o6VEQM+5NDee2bz0xdzWc="
 	generateTimeS := int64(1589588408185) / 1000
 	generateTime := time.Unix(generateTimeS, 0)
@@ -82,12 +76,12 @@ func TestValidateAttestation(t *testing.T) {
 	minValidTime := generateTime.Add(-1 * time.Minute)
 
 	tests := []struct {
-		Opts  VerifyOpts
+		Opts  *VerifyOpts
 		Valid bool
 		Error string
 	}{
 		{
-			VerifyOpts{
+			&VerifyOpts{
 				AppPkgName:      appPackage,
 				APKDigest:       []string{"other digest", apkDigest},
 				Nonce:           nonce,
@@ -100,10 +94,10 @@ func TestValidateAttestation(t *testing.T) {
 			"",
 		},
 		{
-			VerifyOpts{
+			&VerifyOpts{
 				AppPkgName:      appPackage,
 				APKDigest:       []string{""},
-				Nonce:           nil,
+				Nonce:           "",
 				CTSProfileMatch: false,
 				BasicIntegrity:  true,
 				MinValidTime:    minValidTime,
@@ -113,20 +107,7 @@ func TestValidateAttestation(t *testing.T) {
 			"missing nonce",
 		},
 		{
-			VerifyOpts{
-				AppPkgName:      appPackage,
-				APKDigest:       []string{""},
-				Nonce:           &emptyNonce{},
-				CTSProfileMatch: false,
-				BasicIntegrity:  true,
-				MinValidTime:    minValidTime,
-				MaxValidTime:    maxValidTime,
-			},
-			false,
-			"missing nonce",
-		},
-		{
-			VerifyOpts{
+			&VerifyOpts{
 				AppPkgName:      appPackage,
 				APKDigest:       []string{""},
 				Nonce:           nonce,
@@ -139,7 +120,7 @@ func TestValidateAttestation(t *testing.T) {
 			"missing timestamp bounds for attestation",
 		},
 		{
-			VerifyOpts{
+			&VerifyOpts{
 				AppPkgName:      appPackage,
 				APKDigest:       []string{""},
 				Nonce:           nonce,
@@ -152,7 +133,7 @@ func TestValidateAttestation(t *testing.T) {
 			"missing timestamp bounds for attestation",
 		},
 		{
-			VerifyOpts{
+			&VerifyOpts{
 				AppPkgName:      appPackage,
 				APKDigest:       []string{apkDigest},
 				Nonce:           nonce,
@@ -165,7 +146,7 @@ func TestValidateAttestation(t *testing.T) {
 			"missing timestamp bounds for attestation",
 		},
 		{
-			VerifyOpts{
+			&VerifyOpts{
 				AppPkgName:      appPackage,
 				APKDigest:       []string{apkDigest},
 				Nonce:           nonce,
@@ -178,7 +159,7 @@ func TestValidateAttestation(t *testing.T) {
 			"attestation is too old, must be newer than 1589588468, was 1589588408",
 		},
 		{
-			VerifyOpts{
+			&VerifyOpts{
 				AppPkgName:      appPackage,
 				APKDigest:       []string{""},
 				Nonce:           nonce,
@@ -206,3 +187,72 @@ func TestValidateAttestation(t *testing.T) {
 		}
 	}
 }
+
+func TestVerifyOptsFor(t *testing.T) {
+	testTime := time.Date(2020, 1, 13, 5, 6, 4, 6, time.Local)
+
+	cases := []struct {
+		cfg  *model.AuthorizedApp
+		opts *VerifyOpts
+	}{
+		{
+			cfg: &model.AuthorizedApp{
+				AppPackageName:    "foo",
+				CTSProfileMatch:   true,
+				BasicIntegrity:    true,
+				AllowedPastTime:   time.Duration(15 * time.Minute),
+				AllowedFutureTime: time.Duration(1 * time.Second),
+			},
+			opts: &VerifyOpts{
+				AppPkgName:      "foo",
+				APKDigest:       []string{},
+				CTSProfileMatch: true,
+				BasicIntegrity:  true,
+				MinValidTime:    testTime.Add(-15 * time.Minute),
+				MaxValidTime:    testTime.Add(1 * time.Second),
+			},
+		},
+		{
+			cfg: &model.AuthorizedApp{
+				AppPackageName:    "foo",
+				CTSProfileMatch:   false,
+				BasicIntegrity:    true,
+				AllowedPastTime:   0,
+				AllowedFutureTime: 0,
+			},
+			opts: &VerifyOpts{
+				AppPkgName:      "foo",
+				APKDigest:       []string{},
+				CTSProfileMatch: false,
+				BasicIntegrity:  true,
+				MinValidTime:    time.Time{},
+				MaxValidTime:    time.Time{},
+			},
+		},
+		{
+			cfg: &model.AuthorizedApp{
+				AppPackageName:    "foo",
+				ApkDigestSHA256:   []string{"bar"},
+				CTSProfileMatch:   false,
+				BasicIntegrity:    true,
+				AllowedPastTime:   0,
+				AllowedFutureTime: 0,
+			},
+			opts: &VerifyOpts{
+				AppPkgName:      "foo",
+				APKDigest:       []string{"bar"},
+				CTSProfileMatch: false,
+				BasicIntegrity:  true,
+				MinValidTime:    time.Time{},
+				MaxValidTime:    time.Time{},
+			},
+		},
+	}
+
+	for i, c := range cases {
+		got := VerifyOptsFor(c.cfg, testTime, "" /* nonce */)
+		if diff := cmp.Diff(c.opts, got); diff != "" {
+			t.Errorf("%v verify opts (-want +got):\n%v", i, diff)
+		}
+	}
+}
diff --git a/internal/database/apiconfig_test.go b/internal/database/authorized_app_test.go
similarity index 69%
rename from internal/database/apiconfig_test.go
rename to internal/database/authorized_app_test.go
index 58c6e42..d5b7f0a 100644
--- a/internal/database/apiconfig_test.go
+++ b/internal/database/authorized_app_test.go
@@ -25,7 +25,7 @@ import (
 	"testing"
 	"time"
 
-	"github.com/google/exposure-notifications-server/internal/model/apiconfig"
+	"github.com/google/exposure-notifications-server/internal/model"
 	"github.com/google/go-cmp/cmp"
 	"github.com/google/go-cmp/cmp/cmpopts"
 )
@@ -42,7 +42,7 @@ func (s *testSecretManager) GetSecretValue(ctx context.Context, name string) (st
 	return v, nil
 }
 
-func TestReadAPIConfigs(t *testing.T) {
+func TestGetAuthorizedApp(t *testing.T) {
 	if testDB == nil {
 		t.Skip("no test DB")
 	}
@@ -75,88 +75,86 @@ func TestReadAPIConfigs(t *testing.T) {
 		name string
 		sql  string
 		args []interface{}
-		exp  []*apiconfig.APIConfig
+		exp  *model.AuthorizedApp
 		err  bool
 	}{
 		{
 			name: "bare",
 			sql: `
-				INSERT INTO APIConfig (app_package_name, platform, allowed_regions)
+				INSERT INTO AuthorizedApp (app_package_name, platform, allowed_regions)
 				VALUES ($1, $2, $3)
 			`,
 			args: []interface{}{"myapp", "ios", []string{"US"}},
-			exp: []*apiconfig.APIConfig{
-				{
-					AppPackageName:  "myapp",
-					Platform:        "ios",
-					AllowedRegions:  map[string]bool{"US": true},
-					CTSProfileMatch: true,
-					BasicIntegrity:  true,
-				},
+			exp: &model.AuthorizedApp{
+				AppPackageName:  "myapp",
+				Platform:        "ios",
+				AllowedRegions:  map[string]struct{}{"US": {}},
+				CTSProfileMatch: true,
+				BasicIntegrity:  true,
 			},
 		},
 		{
 			name: "allowed_past_time",
 			sql: `
-				INSERT INTO APIConfig (
+				INSERT INTO AuthorizedApp (
 					app_package_name, platform, allowed_regions,
 					allowed_past_seconds
 				) VALUES ($1, $2, $3, $4)
 			`,
 			args: []interface{}{"myapp", "ios", []string{"US"}, 1800},
-			exp: []*apiconfig.APIConfig{
-				{
-					AppPackageName:  "myapp",
-					Platform:        "ios",
-					AllowedRegions:  map[string]bool{"US": true},
-					CTSProfileMatch: true,
-					BasicIntegrity:  true,
-					AllowedPastTime: 30 * time.Minute,
-				},
+			exp: &model.AuthorizedApp{
+				AppPackageName:  "myapp",
+				Platform:        "ios",
+				AllowedRegions:  map[string]struct{}{"US": {}},
+				CTSProfileMatch: true,
+				BasicIntegrity:  true,
+				AllowedPastTime: 30 * time.Minute,
 			},
 		},
 		{
 			name: "allowed_future_time",
 			sql: `
-				INSERT INTO APIConfig (
+				INSERT INTO AuthorizedApp (
 					app_package_name, platform, allowed_regions,
 					allowed_future_seconds
 				) VALUES ($1, $2, $3, $4)
 			`,
 			args: []interface{}{"myapp", "ios", []string{"US"}, 1800},
-			exp: []*apiconfig.APIConfig{
-				{
-					AppPackageName:    "myapp",
-					Platform:          "ios",
-					AllowedRegions:    map[string]bool{"US": true},
-					CTSProfileMatch:   true,
-					BasicIntegrity:    true,
-					AllowedFutureTime: 30 * time.Minute,
-				},
+			exp: &model.AuthorizedApp{
+				AppPackageName:    "myapp",
+				Platform:          "ios",
+				AllowedRegions:    map[string]struct{}{"US": {}},
+				CTSProfileMatch:   true,
+				BasicIntegrity:    true,
+				AllowedFutureTime: 30 * time.Minute,
 			},
 		},
 		{
 			name: "ios_devicecheck",
 			sql: `
-				INSERT INTO APIConfig (
+				INSERT INTO AuthorizedApp (
 					app_package_name, platform, allowed_regions,
 					ios_devicecheck_team_id_secret, ios_devicecheck_key_id_secret, ios_devicecheck_private_key_secret
 				) VALUES ($1, $2, $3, $4, $5, $6)
 			`,
 			args: []interface{}{"myapp", "ios", []string{"US"}, "team_id", "key_id", "private_key"},
-			exp: []*apiconfig.APIConfig{
-				{
-					AppPackageName:        "myapp",
-					Platform:              "ios",
-					AllowedRegions:        map[string]bool{"US": true},
-					CTSProfileMatch:       true,
-					BasicIntegrity:        true,
-					DeviceCheckTeamID:     "ABCD1234",
-					DeviceCheckKeyID:      "DEFG5678",
-					DeviceCheckPrivateKey: p8PrivateKey,
-				},
+			exp: &model.AuthorizedApp{
+				AppPackageName:        "myapp",
+				Platform:              "ios",
+				AllowedRegions:        map[string]struct{}{"US": {}},
+				CTSProfileMatch:       true,
+				BasicIntegrity:        true,
+				DeviceCheckTeamID:     "ABCD1234",
+				DeviceCheckKeyID:      "DEFG5678",
+				DeviceCheckPrivateKey: p8PrivateKey,
 			},
 		},
+		{
+			name: "not_found",
+			sql:  "",
+			args: nil,
+			exp:  nil,
+		},
 	}
 
 	for _, c := range cases {
@@ -175,14 +173,14 @@ func TestReadAPIConfigs(t *testing.T) {
 				t.Fatal(err)
 			}
 
-			configs, err := testDB.ReadAPIConfigs(ctx, sm)
+			config, err := testDB.GetAuthorizedApp(ctx, sm, "myapp")
 			if (err != nil) != c.err {
 				t.Fatal(err)
 			}
 
 			// Compare, ignoring the private key part
 			opts := cmpopts.IgnoreTypes(new(ecdsa.PrivateKey))
-			if diff := cmp.Diff(configs, c.exp, opts); diff != "" {
+			if diff := cmp.Diff(config, c.exp, opts); diff != "" {
 				t.Errorf("mismatch (-want, +got):\n%s", diff)
 			}
 
diff --git a/internal/database/environment.go b/internal/database/config.go
similarity index 100%
rename from internal/database/environment.go
rename to internal/database/config.go
diff --git a/internal/database/database_test.go b/internal/database/database_test.go
index aadbdc1..5ff3b5f 100644
--- a/internal/database/database_test.go
+++ b/internal/database/database_test.go
@@ -111,6 +111,7 @@ func (db *DB) createDatabase(ctx context.Context, name string) error {
 }
 
 func mustExec(t *testing.T, conn *pgxpool.Conn, stmt string, args ...interface{}) {
+	t.Helper()
 	_, err := conn.Exec(context.Background(), stmt, args...)
 	if err != nil {
 		t.Fatalf("executing %s: %v", stmt, err)
@@ -118,6 +119,7 @@ func mustExec(t *testing.T, conn *pgxpool.Conn, stmt string, args ...interface{}
 }
 
 func resetTestDB(t *testing.T) {
+	t.Helper()
 	ctx := context.Background()
 	conn, err := testDB.pool.Acquire(ctx)
 	if err != nil {
@@ -126,9 +128,9 @@ func resetTestDB(t *testing.T) {
 	defer conn.Release()
 
 	_, err = conn.Exec(ctx, `
-		TRUNCATE 
+		TRUNCATE
 			FederationInQuery, FederationInSync, FederationOutAuthorization,
-			Exposure, APIConfig,
+			Exposure, AuthorizedApp,
 			ExportConfig, ExportBatch, ExportFile
 	`)
 	if err != nil {
diff --git a/internal/database/export_test.go b/internal/database/export_test.go
index 51cd2f9..a426ff6 100644
--- a/internal/database/export_test.go
+++ b/internal/database/export_test.go
@@ -26,6 +26,91 @@ import (
 	pgx "github.com/jackc/pgx/v4"
 )
 
+func TestAddSignatureInfo(t *testing.T) {
+	if testDB == nil {
+		t.Skip("no test DB")
+	}
+	defer resetTestDB(t)
+	ctx := context.Background()
+
+	thruTime := time.Now().UTC().Add(6 * time.Hour).Truncate(time.Microsecond)
+	want := &model.SignatureInfo{
+		SigningKey:        "/kms/project/key/1",
+		SigningKeyVersion: "1",
+		SigningKeyID:      "310",
+		EndTimestamp:      thruTime,
+	}
+	if err := testDB.AddSignatureInfo(ctx, want); err != nil {
+		t.Fatal(err)
+	}
+	conn, err := testDB.pool.Acquire(ctx)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer conn.Release()
+	var got model.SignatureInfo
+	err = conn.QueryRow(ctx, `
+		SELECT
+		  id, signing_key, app_package_name, bundle_id, signing_key_version, signing_key_id, thru_timestamp
+		FROM
+			SignatureInfo
+		WHERE
+			id = $1
+	`, want.ID).Scan(&got.ID, &got.SigningKey, &got.AppPackageName, &got.BundleID, &got.SigningKeyVersion, &got.SigningKeyID, &got.EndTimestamp)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if diff := cmp.Diff(want, &got); diff != "" {
+		t.Errorf("mismatch (-want, +got):\n%s", diff)
+	}
+}
+
+func TestLookupSignatureInfos(t *testing.T) {
+	if testDB == nil {
+		t.Skip("no test DB")
+	}
+	defer resetTestDB(t)
+	ctx := context.Background()
+
+	testTime := time.Now().UTC()
+	want := []*model.SignatureInfo{
+		&model.SignatureInfo{
+			SigningKey:        "/kms/project/key/version/1",
+			SigningKeyVersion: "1",
+			SigningKeyID:      "310",
+			EndTimestamp:      testTime.Add(-1 * time.Hour).Truncate(time.Microsecond),
+		},
+		&model.SignatureInfo{
+			SigningKey:        "/kms/project/key/version/2",
+			SigningKeyVersion: "2",
+			SigningKeyID:      "310",
+			EndTimestamp:      testTime.Add(24 * time.Hour).Truncate(time.Microsecond),
+		},
+		&model.SignatureInfo{
+			SigningKey:        "/kms/project/key/version/3",
+			SigningKeyVersion: "3",
+			SigningKeyID:      "310",
+		},
+	}
+	for _, si := range want {
+		testDB.AddSignatureInfo(ctx, si)
+	}
+
+	ids := []int64{want[0].ID, want[1].ID, want[2].ID}
+	got, err := testDB.LookupSignatureInfos(ctx, ids, testTime)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// The first entry (want[0]) is expired and won't be returned.
+	want = want[1:]
+
+	if diff := cmp.Diff(want, got); diff != "" {
+		t.Errorf("mismatch (-want, +got):\n%v", diff)
+	}
+}
+
 func TestAddExportConfig(t *testing.T) {
 	if testDB == nil {
 		t.Skip("no test DB")
@@ -36,13 +121,13 @@ func TestAddExportConfig(t *testing.T) {
 	fromTime := time.Now()
 	thruTime := fromTime.Add(6 * time.Hour)
 	want := &model.ExportConfig{
-		BucketName:   "mocked",
-		FilenameRoot: "root",
-		Period:       3 * time.Hour,
-		Region:       "i1",
-		From:         fromTime,
-		Thru:         thruTime,
-		SigningKey:   "key",
+		BucketName:       "mocked",
+		FilenameRoot:     "root",
+		Period:           3 * time.Hour,
+		Region:           "i1",
+		From:             fromTime,
+		Thru:             thruTime,
+		SignatureInfoIDs: []int64{42, 84},
 	}
 	if err := testDB.AddExportConfig(ctx, want); err != nil {
 		t.Fatal(err)
@@ -58,12 +143,12 @@ func TestAddExportConfig(t *testing.T) {
 	)
 	err = conn.QueryRow(ctx, `
 		SELECT
-			config_id, bucket_name, filename_root, period_seconds, region, from_timestamp, thru_timestamp, signing_key
+			config_id, bucket_name, filename_root, period_seconds, region, from_timestamp, thru_timestamp, signature_info_ids
 		FROM
 			ExportConfig
 		WHERE
 			config_id = $1
-	`, want.ConfigID).Scan(&got.ConfigID, &got.BucketName, &got.FilenameRoot, &psecs, &got.Region, &got.From, &got.Thru, &got.SigningKey)
+	`, want.ConfigID).Scan(&got.ConfigID, &got.BucketName, &got.FilenameRoot, &psecs, &got.Region, &got.From, &got.Thru, &got.SignatureInfoIDs)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -141,12 +226,13 @@ func TestBatches(t *testing.T) {
 
 	now := time.Now().Truncate(time.Microsecond)
 	config := &model.ExportConfig{
-		BucketName:   "mocked",
-		FilenameRoot: "root",
-		Period:       time.Hour,
-		Region:       "R",
-		From:         now,
-		Thru:         now.Add(time.Hour),
+		BucketName:       "mocked",
+		FilenameRoot:     "root",
+		Period:           time.Hour,
+		Region:           "R",
+		From:             now,
+		Thru:             now.Add(time.Hour),
+		SignatureInfoIDs: []int64{1, 2, 3, 4},
 	}
 	if err := testDB.AddExportConfig(ctx, config); err != nil {
 		t.Fatal(err)
@@ -158,13 +244,14 @@ func TestBatches(t *testing.T) {
 		end := start.Add(time.Minute)
 		wantLatest = end
 		batches = append(batches, &model.ExportBatch{
-			ConfigID:       config.ConfigID,
-			BucketName:     config.BucketName,
-			FilenameRoot:   config.FilenameRoot,
-			Region:         config.Region,
-			Status:         model.ExportBatchOpen,
-			StartTimestamp: start,
-			EndTimestamp:   end,
+			ConfigID:         config.ConfigID,
+			BucketName:       config.BucketName,
+			FilenameRoot:     config.FilenameRoot,
+			Region:           config.Region,
+			Status:           model.ExportBatchOpen,
+			StartTimestamp:   start,
+			EndTimestamp:     end,
+			SignatureInfoIDs: []int64{1, 2, 3, 4},
 		})
 	}
 	if err := testDB.AddExportBatches(ctx, batches); err != nil {
diff --git a/internal/export/worker_test.go b/internal/export/worker_test.go
index 650f0c5..c522e65 100644
--- a/internal/export/worker_test.go
+++ b/internal/export/worker_test.go
@@ -49,10 +49,10 @@ func TestDoNotPadZeroLength(t *testing.T) {
 	exposures := make([]*model.Exposure, 0)
 	exposures, err := ensureMinNumExposures(exposures, "US", 1000, 100)
 	if err != nil {
-		t.Fatalf("unexpected error: %v", err)
+		t.Fatalf("unepected error: %v", err)
 	}
 	if len(exposures) != 0 {
-		t.Errorf("zero length slice shouldn't have been padded, but was")
+		t.Errorf("empty exposure list got padded, shouldn't have.")
 	}
 }
 
diff --git a/internal/jsonutil/unmarshal_test.go b/internal/jsonutil/unmarshal_test.go
index 3a0ac82..81b6b46 100644
--- a/internal/jsonutil/unmarshal_test.go
+++ b/internal/jsonutil/unmarshal_test.go
@@ -158,6 +158,7 @@ func TestValidPublishMessage(t *testing.T) {
 }
 
 func unmarshalTestHelper(t *testing.T, payloads []string, errors []string, expCode int) {
+	t.Helper()
 	for i, testStr := range payloads {
 		body := ioutil.NopCloser(bytes.NewReader([]byte(testStr)))
 		r := httptest.NewRequest("POST", "/", body)
diff --git a/internal/model/apiconfig/config_test.go b/internal/model/apiconfig/config_test.go
deleted file mode 100644
index c2a49b1..0000000
--- a/internal/model/apiconfig/config_test.go
+++ /dev/null
@@ -1,104 +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 apiconfig
-
-import (
-	"testing"
-	"time"
-
-	"github.com/google/exposure-notifications-server/internal/android"
-
-	"github.com/google/go-cmp/cmp"
-)
-
-func TestBaseAPIConfig(t *testing.T) {
-
-	cfg := New()
-	if cfg.IsIOS() {
-		t.Errorf("cfg.IoIOS, got true, want false")
-	}
-	if cfg.IsAndroid() {
-		t.Errorf("cfg.IoAndroid, got true, want false")
-	}
-}
-
-func TestVerifyOpts(t *testing.T) {
-	testTime := time.Date(2020, 1, 13, 5, 6, 4, 6, time.Local)
-
-	cases := []struct {
-		cfg  *APIConfig
-		opts android.VerifyOpts
-	}{
-		{
-			cfg: &APIConfig{
-				AppPackageName:    "foo",
-				CTSProfileMatch:   true,
-				BasicIntegrity:    true,
-				AllowedPastTime:   time.Duration(15 * time.Minute),
-				AllowedFutureTime: time.Duration(1 * time.Second),
-			},
-			opts: android.VerifyOpts{
-				AppPkgName:      "foo",
-				APKDigest:       []string{},
-				CTSProfileMatch: true,
-				BasicIntegrity:  true,
-				MinValidTime:    testTime.Add(-15 * time.Minute),
-				MaxValidTime:    testTime.Add(1 * time.Second),
-			},
-		},
-		{
-			cfg: &APIConfig{
-				AppPackageName:    "foo",
-				CTSProfileMatch:   false,
-				BasicIntegrity:    true,
-				AllowedPastTime:   0,
-				AllowedFutureTime: 0,
-			},
-			opts: android.VerifyOpts{
-				AppPkgName:      "foo",
-				APKDigest:       []string{},
-				CTSProfileMatch: false,
-				BasicIntegrity:  true,
-				MinValidTime:    time.Time{},
-				MaxValidTime:    time.Time{},
-			},
-		},
-		{
-			cfg: &APIConfig{
-				AppPackageName:    "foo",
-				ApkDigestSHA256:   []string{"bar"},
-				CTSProfileMatch:   false,
-				BasicIntegrity:    true,
-				AllowedPastTime:   0,
-				AllowedFutureTime: 0,
-			},
-			opts: android.VerifyOpts{
-				AppPkgName:      "foo",
-				APKDigest:       []string{"bar"},
-				CTSProfileMatch: false,
-				BasicIntegrity:  true,
-				MinValidTime:    time.Time{},
-				MaxValidTime:    time.Time{},
-			},
-		},
-	}
-
-	for i, tst := range cases {
-		got := tst.cfg.VerifyOpts(testTime, nil /* noncer */)
-		if diff := cmp.Diff(tst.opts, got); diff != "" {
-			t.Errorf("%v verify opts (-want +got):\n%v", i, diff)
-		}
-	}
-}
diff --git a/internal/model/exposure_test.go b/internal/model/exposure_test.go
index 7341659..35bc3fc 100644
--- a/internal/model/exposure_test.go
+++ b/internal/model/exposure_test.go
@@ -237,7 +237,82 @@ func TestPublishValidation(t *testing.T) {
 	}
 }
 
+// Data from this test was generated by the Android reference application.
+func TestPublish_AndroidNonce(t *testing.T) {
+	cases := []struct {
+		data *Publish
+		exp  string
+	}{
+		{
+			data: &Publish{
+				Keys: []ExposureKey{
+					{
+						Key:              "x21Goi8X9m/glOZ0+wz8fA",
+						IntervalNumber:   263123,
+						IntervalCount:    144,
+						TransmissionRisk: 6,
+					},
+					{
+						Key:              "2mvFSmRsFmJR5r07dxGSjg",
+						IntervalNumber:   263267,
+						IntervalCount:    144,
+						TransmissionRisk: 7,
+					},
+					{
+						Key:              "6bAd3dv7p+VEuaJVkVItaQ",
+						IntervalNumber:   263411,
+						IntervalCount:    27,
+						TransmissionRisk: 7,
+					},
+				},
+				Regions:             []string{"GB", "US"},
+				AppPackageName:      "com.google.android.apps.exposurenotification",
+				VerificationPayload: "QRTH-ROWO-LOLO-FOOB",
+			},
+			exp: "0kbOC1LIwDc+DQnLOFcIbnxqfKNg58b7WA3vREdd7ZQ=",
+		},
+		{
+			data: &Publish{
+				Keys: []ExposureKey{
+					{
+						Key:              "zdCW5HrOKbirxmQVc0L/eA",
+						IntervalNumber:   263123,
+						IntervalCount:    144,
+						TransmissionRisk: 8,
+					},
+					{
+						Key:              "t+k51ifogJo9jq3GH9LWGQ",
+						IntervalNumber:   263267,
+						IntervalCount:    144,
+						TransmissionRisk: 8,
+					},
+					{
+						Key:              "3uXRrSlcv1+OMI3oFtdaUw",
+						IntervalNumber:   263411,
+						IntervalCount:    27,
+						TransmissionRisk: 8,
+					},
+				},
+				Regions:             []string{"gB", "us"},
+				AppPackageName:      "com.google.android.apps.exposurenotification",
+				VerificationPayload: "BREA-KMEO-FFAP-IECE",
+			},
+			exp: "KYCIywFRTwXCnYCF4TTFaH//iMb/fBQENqIhtzrJPxA=",
+		},
+	}
+
+	for _, c := range cases {
+		c := c
+
+		nonce := c.data.AndroidNonce()
+		if nonce != c.exp {
+			t.Errorf("getNonce: got '%v', want '%v'", nonce, c.exp)
+		}
+	}
+}
+
 func generateKey(t *testing.T) []byte {
+	t.Helper()
 	key := make([]byte, 16)
 	_, err := rand.Read(key)
 	if err != nil {
diff --git a/internal/secrets/cacher_test.go b/internal/secrets/cacher_test.go
new file mode 100644
index 0000000..2b25062
--- /dev/null
+++ b/internal/secrets/cacher_test.go
@@ -0,0 +1,82 @@
+// 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 secrets
+
+import (
+	"context"
+	"testing"
+	"time"
+)
+
+type testSecretManager struct {
+	value string
+	hits  int
+}
+
+func (sm *testSecretManager) GetSecretValue(ctx context.Context, name string) (string, error) {
+	sm.hits++
+	return sm.value, nil
+}
+
+func TestCacher_GetSecretValue(t *testing.T) {
+	ctx := context.Background()
+
+	sm := &testSecretManager{value: "first"}
+	cached, err := WrapCacher(ctx, sm, 250*time.Millisecond)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// Read the value once, which should cache it.
+	if _, err := cached.GetSecretValue(ctx, "secret"); err != nil {
+		t.Fatal(err)
+	}
+
+	// Change the value.
+	sm.value = "second"
+
+	// Read the value a few more times.
+	for i := 0; i < 5; i++ {
+		got, err := cached.GetSecretValue(ctx, "secret")
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		if want := "first"; got != want {
+			t.Errorf("expected %q to be %q", got, want)
+		}
+
+		if sm.hits > 1 {
+			t.Errorf("cache miss: %d", sm.hits)
+		}
+	}
+
+	// Wait for cache to expire.
+	time.Sleep(300 * time.Millisecond)
+
+	// Try again - should miss
+	got, err := cached.GetSecretValue(ctx, "secret")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if want := "second"; got != want {
+		t.Errorf("expected %q to be %q", got, want)
+	}
+
+	if sm.hits != 2 {
+		t.Errorf("expected another hit: %d", sm.hits)
+	}
+}
diff --git a/internal/verification/verify_test.go b/internal/verification/verify_test.go
index d59aff4..adefc28 100644
--- a/internal/verification/verify_test.go
+++ b/internal/verification/verify_test.go
@@ -17,12 +17,12 @@ package verification
 import (
 	"context"
 	"fmt"
+	"strings"
 	"testing"
 	"time"
 
 	"github.com/google/exposure-notifications-server/internal/android"
 	"github.com/google/exposure-notifications-server/internal/model"
-	"github.com/google/exposure-notifications-server/internal/model/apiconfig"
 )
 
 const (
@@ -30,21 +30,21 @@ const (
 )
 
 func TestVerifyRegions(t *testing.T) {
-	allRegions := &apiconfig.APIConfig{
+	allRegions := &model.AuthorizedApp{
 		AppPackageName:  appPkgName,
 		AllowAllRegions: true,
 	}
-	usCaRegions := &apiconfig.APIConfig{
+	usCaRegions := &model.AuthorizedApp{
 		AppPackageName: appPkgName,
-		AllowedRegions: make(map[string]bool),
+		AllowedRegions: make(map[string]struct{}),
 	}
-	usCaRegions.AllowedRegions["US"] = true
-	usCaRegions.AllowedRegions["CA"] = true
+	usCaRegions.AllowedRegions["US"] = struct{}{}
+	usCaRegions.AllowedRegions["CA"] = struct{}{}
 
 	cases := []struct {
 		Data *model.Publish
 		Msg  string
-		Cfg  *apiconfig.APIConfig
+		Cfg  *model.AuthorizedApp
 	}{
 		{
 			&model.Publish{Regions: []string{"US"}},
@@ -89,26 +89,21 @@ func TestVerifyRegions(t *testing.T) {
 }
 
 func TestVerifySafetyNet(t *testing.T) {
-	allRegions := &apiconfig.APIConfig{
+	allRegions := &model.AuthorizedApp{
 		AppPackageName:  appPkgName,
 		AllowAllRegions: true,
 	}
-	allRegionsSafetyCheckDisabled := &apiconfig.APIConfig{
-		AppPackageName:  appPkgName,
-		AllowAllRegions: true,
-		BypassSafetyNet: true,
-	}
 
 	cases := []struct {
 		Data              *model.Publish
 		Msg               string
-		Cfg               *apiconfig.APIConfig
+		Cfg               *model.AuthorizedApp
 		AttestationResult error
 	}{
 		{
 			// With no configuration, return err.
 			&model.Publish{Regions: []string{"US"}},
-			"cannot enforce safetynet, no application config",
+			"cannot enforce SafetyNet",
 			nil,
 			nil,
 		}, {
@@ -124,19 +119,12 @@ func TestVerifySafetyNet(t *testing.T) {
 			"android.ValidateAttestation: mocked",
 			allRegions,
 			fmt.Errorf("mocked"),
-		}, {
-			// Verify when ValidateAttestation raises err, with safety check
-			// disabled, return nil.
-			&model.Publish{Regions: []string{"US"}},
-			"",
-			allRegionsSafetyCheckDisabled,
-			fmt.Errorf("mocked"),
 		},
 	}
 
 	for i, c := range cases {
 		var ctx = context.Background()
-		androidValidateAttestation = func(context.Context, string, android.VerifyOpts) error {
+		androidValidateAttestation = func(context.Context, string, *android.VerifyOpts) error {
 			return c.AttestationResult
 		}
 
@@ -148,7 +136,7 @@ func TestVerifySafetyNet(t *testing.T) {
 			t.Errorf("%v got %v, wanted no error", i, err)
 			continue
 		}
-		if err.Error() != c.Msg {
+		if !strings.Contains(err.Error(), c.Msg) {
 			t.Errorf("%v wrong error, got %v, want %v", i, err, c.Msg)
 		}
 	}