diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 152a4d7c42ca5d3431defe5e9b7dc545d7236513..7a8b4e79d3675947cb6f24351fa4c4706313af27 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -8,6 +8,17 @@ unit-tests:
   script:
     - "nix-shell --run 'nix-build nixos/unit-tests.nix' && cat result"
 
+vulnerability-scan:
+  stage: "test"
+  script:
+    - "ci-tools/vulnerability-scan security-report.json"
+    - "ci-tools/count-vulnerabilities <security-report.json"
+  artifacts:
+    paths:
+      - "security-report.json"
+    expose_as: "security report"
+
+
 system-tests:
   stage: "test"
   timeout: "3 hours"
@@ -18,15 +29,20 @@ deploy-to-staging:
   stage: "deploy"
   only:
     - "staging"
+  environment:
+    name: "staging"
+    url: "https://privatestorage-staging.com/"
   script:
-    - echo -n "Hello $GITLAB_USER_LOGIN from $CI_JOB_NAME. I was triggered by $CI_PIPELINE_SOURCE "
-    - echo "and would like to deploy the $CI_COMMIT_BRANCH branch to the staging environment."
+    - echo "Hello $GITLAB_USER_LOGIN from $CI_JOB_NAME. I was triggered by $CI_PIPELINE_SOURCE "
+    - echo "and would like to deploy the $CI_COMMIT_BRANCH branch to the $CI_ENVIRONMENT_NAME environment."
 
 deploy-to-production:
   stage: "deploy"
   only:
     - "production"
+  environment:
+    name: "production"
+    url: "https://privatestorage.io/"
   script:
-    - echo -n "Hello $GITLAB_USER_LOGIN from $CI_JOB_NAME. I was triggered by $CI_PIPELINE_SOURCE "
-    - echo "and would like to deploy the $CI_COMMIT_BRANCH branch to the production environment."
-
+    - echo "Hello $GITLAB_USER_LOGIN from $CI_JOB_NAME. I was triggered by $CI_PIPELINE_SOURCE "
+    - echo "and would like to deploy the $CI_COMMIT_BRANCH branch to the $CI_ENVIRONMENT_NAME environment."
diff --git a/ci-tools/count-vulnerabilities b/ci-tools/count-vulnerabilities
new file mode 100755
index 0000000000000000000000000000000000000000..9db1c5e7e3aa756dc5b151fbcc30bc4572dd1eba
--- /dev/null
+++ b/ci-tools/count-vulnerabilities
@@ -0,0 +1,14 @@
+#!/usr/bin/env python3
+
+from sys import stdin
+from json import load
+
+def main():
+    vulnix_report = load(stdin)
+    print("Vulnerable packages: {}".format(len(vulnix_report)))
+    print("Vulnerability count: {}".format(
+        len(set(sum((deriv["affected_by"] for deriv in vulnix_report), []))),
+    ))
+
+if __name__ == '__main__':
+    main()
diff --git a/ci-tools/vulnerability-scan b/ci-tools/vulnerability-scan
new file mode 100755
index 0000000000000000000000000000000000000000..48bf51e071a398f37565717a22b2066d3f905fbe
--- /dev/null
+++ b/ci-tools/vulnerability-scan
@@ -0,0 +1,54 @@
+#!/usr/bin/env sh
+
+set -xeo pipefail
+
+#
+# `morph build ...` output is like
+#
+#   Selected 2/2 hosts (name filter:-0, limits:-0):
+#             0: xx.xx.xx.xx (secrets: 1, health checks: 0)
+#             1: yy.yy.yy.yy (secrets: 2, health checks: 0)
+#
+#   /nix/store/d7spc457nnzh0rnv0f5lh1q2j435j1b9-morph
+#   nix result path:
+#   /nix/store/d7spc457nnzh0rnv0f5lh1q2j435j1b9-morph
+#
+# Get the last line so we can scan it.
+#
+
+OUTPUT=$1
+
+[ -e scan-target ] && rm -v scan-target
+nix-shell --run '
+set -x
+if morph_result=$(morph build morph/grid/testing/grid.nix 2>&1); then
+  object=$(echo "$morph_result" | tail -n 1)
+  ln -s "$object" scan-target
+else
+  echo "$morph_result"
+
+  # exit status 0-3 reserved for vulnix result.
+  exit 4
+fi
+'
+
+# vulnix exits with an error status if there are vulnerabilities.  We told
+# GitLab to allow this by setting `allow_failure` to true in the GitLab CI
+# config.  vulnix exit status indicates what vulnix thinks happened.  If we
+# upgrade to a newer GitLab then we can make GitLab pipeline behavior vary
+# based on this.
+#
+# For now, allow 0 (no errors), 1 (only whitelisted errors), and 2
+# (non-whitelisted errors).  3 indicates unexpected error so we let that
+# propagate.
+set +e
+nix-shell -p vulnix --run 'vulnix --json ./scan-target/' | tee "$OUTPUT"
+vulnix_status=$?
+set -e
+
+echo "vulnix status: $vulnix_status"
+if [ $vulnix_status -eq 3 ]; then
+    exit $vulnix_status
+else
+    exit 0
+fi
diff --git a/morph/grid/testing/grid.nix b/morph/grid/testing/grid.nix
index 3a1c5f3921c196843b0a4cd1b18f20388a75edde..65f97a97fa949ac0eed0249c4b89bd7399ebf436 100644
--- a/morph/grid/testing/grid.nix
+++ b/morph/grid/testing/grid.nix
@@ -6,7 +6,12 @@ import ../../lib/make-grid.nix {
   config = ./config.json;
   nodes = cfg:
   let
-    sshUsers = import ../../../../PrivateStorageSecrets/staging-users.nix;
+    importDef = default: path: (
+      if builtins.pathExists path
+      then import path
+      else default
+    );
+    sshUsers = importDef {} ../../../../PrivateStorageSecrets/staging-users.nix;
   in {
     "payments.privatestorage-staging.com" = import ../../lib/issuer.nix ({
       inherit sshUsers;
diff --git a/nixos/pkgs/zkapauthorizer-repo.nix b/nixos/pkgs/zkapauthorizer-repo.nix
index dffc40ed22d2fb0113fd033f1cf12c09b91eb0dd..595e7b4c8f6e5336fe48c50b22a7a9cc8b8d00db 100644
--- a/nixos/pkgs/zkapauthorizer-repo.nix
+++ b/nixos/pkgs/zkapauthorizer-repo.nix
@@ -4,6 +4,6 @@ in
   pkgs.fetchFromGitHub {
     owner = "PrivateStorageio";
     repo = "ZKAPAuthorizer";
-    rev = "6cd0c32cc53a9734e2cb7c19a9ad28d479612197";
-    sha256 = "0i1r75471yjj4bfi5814ihrcyjk1zdz8rzm06bngij3n70svqhsm";
+    rev = "e4430a0050cef286b723da7f8013c7affd5a58f7";
+    sha256 = "148d79zppsd6bnyagbx126s9x9yy975dx6rrbm26dh98kl1r8mbh";
   }
\ No newline at end of file