diff --git a/.eslintrc.yml b/.eslintrc.yml
index 153b7513df785f431b9fe9adb0d982bd6cfe08a7..fb634c5880efc10a395122520458d3dde035abd2 100644
--- a/.eslintrc.yml
+++ b/.eslintrc.yml
@@ -30,6 +30,7 @@ overrides:
                 'category',
                 'isDeprecated',
                 'route',
+                'auth',
                 'examples',
                 '_cacheLength',
                 'defaultBadgeData',
diff --git a/core/base-service/auth-helper.js b/core/base-service/auth-helper.js
new file mode 100644
index 0000000000000000000000000000000000000000..784311d6d64dfb704d319209fb14362df897bfd5
--- /dev/null
+++ b/core/base-service/auth-helper.js
@@ -0,0 +1,43 @@
+'use strict'
+
+class AuthHelper {
+  constructor({ userKey, passKey, isRequired = false }, privateConfig) {
+    if (!userKey && !passKey) {
+      throw Error('Expected userKey or passKey to be set')
+    }
+
+    this._userKey = userKey
+    this._passKey = passKey
+    this.user = userKey ? privateConfig[userKey] : undefined
+    this.pass = passKey ? privateConfig[passKey] : undefined
+    this.isRequired = isRequired
+  }
+
+  get isConfigured() {
+    return (
+      (this._userKey ? Boolean(this.user) : true) &&
+      (this._passKey ? Boolean(this.pass) : true)
+    )
+  }
+
+  get isValid() {
+    if (this.isRequired) {
+      return this.isConfigured
+    } else {
+      const configIsEmpty = !this.user && !this.pass
+      return this.isConfigured || configIsEmpty
+    }
+  }
+
+  get basicAuth() {
+    const { user, pass } = this
+    return this.isConfigured ? { user, pass } : undefined
+  }
+
+  get bearerAuthHeader() {
+    const { pass } = this
+    return this.isConfigured ? { Authorization: `Bearer ${pass}` } : undefined
+  }
+}
+
+module.exports = { AuthHelper }
diff --git a/core/base-service/auth-helper.spec.js b/core/base-service/auth-helper.spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..3bf4887cd35bb6ff65e55333eb5a1d46211a0c96
--- /dev/null
+++ b/core/base-service/auth-helper.spec.js
@@ -0,0 +1,96 @@
+'use strict'
+
+const { expect } = require('chai')
+const { test, given, forCases } = require('sazerac')
+const { AuthHelper } = require('./auth-helper')
+
+describe('AuthHelper', function() {
+  it('throws without userKey or passKey', function() {
+    expect(() => new AuthHelper({}, {})).to.throw(
+      Error,
+      'Expected userKey or passKey to be set'
+    )
+  })
+
+  describe('isValid', function() {
+    function validate(config, privateConfig) {
+      return new AuthHelper(config, privateConfig).isValid
+    }
+    test(validate, () => {
+      forCases([
+        // Fully configured user + pass.
+        given(
+          { userKey: 'myci_user', passKey: 'myci_pass', isRequired: true },
+          { myci_user: 'admin', myci_pass: 'abc123' }
+        ),
+        given(
+          { userKey: 'myci_user', passKey: 'myci_pass' },
+          { myci_user: 'admin', myci_pass: 'abc123' }
+        ),
+        // Fully configured user or pass.
+        given(
+          { userKey: 'myci_user', isRequired: true },
+          { myci_user: 'admin' }
+        ),
+        given(
+          { passKey: 'myci_pass', isRequired: true },
+          { myci_pass: 'abc123' }
+        ),
+        given({ userKey: 'myci_user' }, { myci_user: 'admin' }),
+        given({ passKey: 'myci_pass' }, { myci_pass: 'abc123' }),
+        // Empty config.
+        given({ userKey: 'myci_user', passKey: 'myci_pass' }, {}),
+        given({ userKey: 'myci_user' }, {}),
+        given({ passKey: 'myci_pass' }, {}),
+      ]).expect(true)
+
+      forCases([
+        // Partly configured.
+        given(
+          { userKey: 'myci_user', passKey: 'myci_pass', isRequired: true },
+          { myci_user: 'admin' }
+        ),
+        given(
+          { userKey: 'myci_user', passKey: 'myci_pass' },
+          { myci_user: 'admin' }
+        ),
+        // Missing required config.
+        given(
+          { userKey: 'myci_user', passKey: 'myci_pass', isRequired: true },
+          {}
+        ),
+        given({ userKey: 'myci_user', isRequired: true }, {}),
+        given({ passKey: 'myci_pass', isRequired: true }, {}),
+      ]).expect(false)
+    })
+  })
+
+  describe('basicAuth', function() {
+    function validate(config, privateConfig) {
+      return new AuthHelper(config, privateConfig).basicAuth
+    }
+    test(validate, () => {
+      forCases([
+        given(
+          { userKey: 'myci_user', passKey: 'myci_pass', isRequired: true },
+          { myci_user: 'admin', myci_pass: 'abc123' }
+        ),
+        given(
+          { userKey: 'myci_user', passKey: 'myci_pass' },
+          { myci_user: 'admin', myci_pass: 'abc123' }
+        ),
+      ]).expect({ user: 'admin', pass: 'abc123' })
+      given({ userKey: 'myci_user' }, { myci_user: 'admin' }).expect({
+        user: 'admin',
+        pass: undefined,
+      })
+      given({ passKey: 'myci_pass' }, { myci_pass: 'abc123' }).expect({
+        user: undefined,
+        pass: 'abc123',
+      })
+      given({ userKey: 'myci_user', passKey: 'myci_pass' }, {}).expect(
+        undefined
+      )
+    })
+  })
+})
diff --git a/core/base-service/base.js b/core/base-service/base.js
index 736ea13fc7c595b5a513536cef9c5bca896012cb..4db3ecae4abbe059393a9f75c8defa36ec51f5a2 100644
--- a/core/base-service/base.js
+++ b/core/base-service/base.js
@@ -4,6 +4,7 @@ const decamelize = require('decamelize')
 // See available emoji at http://emoji.muan.co/
 const emojic = require('emojic')
 const Joi = require('@hapi/joi')
+const { AuthHelper } = require('./auth-helper')
 const { assertValidCategory } = require('./categories')
 const checkErrorResponse = require('./check-error-response')
 const coalesceBadge = require('./coalesce-badge')
@@ -11,6 +12,7 @@ const {
   NotFound,
   InvalidResponse,
   Inaccessible,
+  ImproperlyConfigured,
   InvalidParameter,
   Deprecated,
 } = require('./errors')
@@ -132,6 +134,33 @@ module.exports = class BaseService {
     throw new Error(`Route not defined for ${this.name}`)
   }
 
+  /**
+   * Configuration for the authentication helper that prepares credentials
+   * for upstream requests.
+   *
+   * @abstract
+   * @return {object} auth
+   * @return {string} auth.userKey
+   *    (Optional) The key from `privateConfig` to use as the username.
+   * @return {string} auth.passKey
+   *    (Optional) The key from `privateConfig` to use as the password.
+   *    If auth is configured, either `userKey` or `passKey` is required.
+   * @return {string} auth.isRequired
+   *    (Optional) If `true`, the service will return `NotFound` unless the
+   *    configured credentials are present.
+   *
+   * See also the config schema in `./server.js` and `doc/server-secrets.md`.
+   *
+   * To use the configured auth in the handler or fetch method, pass the
+   * credentials to the request. For example:
+   * `{ options: { auth: this.authHelper.basicAuth } }`
+   * `{ options: { headers: this.authHelper.bearerAuthHeader } }`
+   * `{ options: { qs: { token: this.authHelper.pass } } }`
+   */
+  static get auth() {
+    return undefined
+  }
+
   /**
    * Example URLs for this service. These should use the format
    * specified in `route`, and can be used to demonstrate how to use badges for
@@ -238,8 +267,9 @@ module.exports = class BaseService {
     return result
   }
 
-  constructor({ sendAndCacheRequest }, { handleInternalErrors }) {
+  constructor({ sendAndCacheRequest, authHelper }, { handleInternalErrors }) {
     this._requestFetcher = sendAndCacheRequest
+    this.authHelper = authHelper
     this._handleInternalErrors = handleInternalErrors
   }
 
@@ -303,6 +333,7 @@ module.exports = class BaseService {
         color: 'red',
       }
     } else if (
+      error instanceof ImproperlyConfigured ||
       error instanceof InvalidResponse ||
       error instanceof Inaccessible ||
       error instanceof Deprecated
@@ -353,12 +384,26 @@ module.exports = class BaseService {
     trace.logTrace('inbound', emojic.ticket, 'Named params', namedParams)
     trace.logTrace('inbound', emojic.crayon, 'Query params', queryParams)
 
-    const serviceInstance = new this(context, config)
+    // Like the service instance, the auth helper could be reused for each request.
+    // However, moving its instantiation to `register()` makes `invoke()` harder
+    // to test.
+    const authHelper = this.auth
+      ? new AuthHelper(this.auth, config.private)
+      : undefined
+
+    const serviceInstance = new this({ ...context, authHelper }, config)
 
     let serviceError
+    if (authHelper && !authHelper.isValid) {
+      const prettyMessage = authHelper.isRequired
+        ? 'credentials have not been configured'
+        : 'credentials are misconfigured'
+      serviceError = new ImproperlyConfigured({ prettyMessage })
+    }
+
     const { queryParamSchema } = this.route
     let transformedQueryParams
-    if (queryParamSchema) {
+    if (!serviceError && queryParamSchema) {
       try {
         transformedQueryParams = validate(
           {
diff --git a/core/base-service/base.spec.js b/core/base-service/base.spec.js
index 0a9297fca5b992bb4991967b6972ab2ed3f0fa63..7c0d68e1e2a4bd4dad076d0410c9c963301e2cbc 100644
--- a/core/base-service/base.spec.js
+++ b/core/base-service/base.spec.js
@@ -64,7 +64,7 @@ class DummyService extends BaseService {
 }
 
 describe('BaseService', function() {
-  const defaultConfig = { handleInternalErrors: false }
+  const defaultConfig = { handleInternalErrors: false, private: {} }
 
   it('Invokes the handler as expected', async function() {
     expect(
@@ -482,4 +482,43 @@ describe('BaseService', function() {
       }
     })
   })
+
+  describe('auth', function() {
+    class AuthService extends DummyService {
+      static get auth() {
+        return {
+          passKey: 'myci_pass',
+          isRequired: true,
+        }
+      }
+
+      async handle() {
+        return {
+          message: `The CI password is ${this.authHelper.pass}`,
+        }
+      }
+    }
+
+    it('when auth is configured properly, invoke() sets authHelper', async function() {
+      expect(
+        await AuthService.invoke(
+          {},
+          { defaultConfig, private: { myci_pass: 'abc123' } },
+          { namedParamA: 'bar.bar.bar' }
+        )
+      ).to.deep.equal({ message: 'The CI password is abc123' })
+    })
+
+    it('when auth is not configured properly, invoke() returns inacessible', async function() {
+      expect(
+        await AuthService.invoke({}, defaultConfig, {
+          namedParamA: 'bar.bar.bar',
+        })
+      ).to.deep.equal({
+        color: 'lightgray',
+        isError: true,
+        message: 'credentials have not been configured',
+      })
+    })
+  })
 })
diff --git a/core/base-service/errors.js b/core/base-service/errors.js
index 7c5d2aa5c116a5a8d328c722a8da9acda5e3c342..14286b1c431b32cce1594336a4117c482e3ecfe2 100644
--- a/core/base-service/errors.js
+++ b/core/base-service/errors.js
@@ -72,6 +72,23 @@ class Inaccessible extends ShieldsRuntimeError {
   }
 }
 
+class ImproperlyConfigured extends ShieldsRuntimeError {
+  get name() {
+    return 'ImproperlyConfigured'
+  }
+  get defaultPrettyMessage() {
+    return 'improperly configured'
+  }
+
+  constructor(props = {}) {
+    const message = props.underlyingError
+      ? `ImproperlyConfigured: ${props.underlyingError.message}`
+      : 'ImproperlyConfigured'
+    super(props, message)
+    this.response = props.response
+  }
+}
+
 class InvalidParameter extends ShieldsRuntimeError {
   get name() {
     return 'InvalidParameter'
@@ -106,6 +123,7 @@ class Deprecated extends ShieldsRuntimeError {
 module.exports = {
   ShieldsRuntimeError,
   NotFound,
+  ImproperlyConfigured,
   InvalidResponse,
   Inaccessible,
   InvalidParameter,
diff --git a/core/server/server.js b/core/server/server.js
index 4dd1d205831995ee266bd930354352bbf5432296..1800d59dbda6dceaa32d97f9040eed9a43d561f2 100644
--- a/core/server/server.js
+++ b/core/server/server.js
@@ -248,6 +248,7 @@ module.exports = class Server {
           profiling: config.public.profiling,
           fetchLimitBytes: bytes(config.public.fetchLimit),
           rasterUrl: config.public.rasterUrl,
+          private: config.private,
         }
       )
     )
diff --git a/dangerfile.js b/dangerfile.js
index 886b18e092eb7b933b38ad8f506449f0fdf6ef31..ce5461dc5cf935504b1667d476a6019325f08f0c 100644
--- a/dangerfile.js
+++ b/dangerfile.js
@@ -112,10 +112,13 @@ if (allFiles.length > 100) {
 
     // eslint-disable-next-line promise/prefer-await-to-then
     danger.git.diffForFile(file).then(({ diff }) => {
-      if (diff.includes('serverSecrets') && !secretsDocs.modified) {
+      if (
+        (diff.includes('authHelper') || diff.includes('serverSecrets')) &&
+        !secretsDocs.modified
+      ) {
         warn(
           [
-            `:books: Remember to ensure any changes to \`serverSecrets\` `,
+            `:books: Remember to ensure any changes to \`config.private\` `,
             `in \`${file}\` are reflected in the [server secrets documentation]`,
             '(https://github.com/badges/shields/blob/master/doc/server-secrets.md)',
           ].join('')
diff --git a/server.js b/server.js
index efc81f32ba1d2cbf2525d4e8e34cfb6c21850a9c..0ebdc3f9024b5c7229e9f53ccf96f507169a561f 100644
--- a/server.js
+++ b/server.js
@@ -1,16 +1,17 @@
 'use strict'
 /* eslint-disable import/order */
 
+const fs = require('fs')
+const path = require('path')
+
 require('dotenv').config()
 
 // Set up Sentry reporting as early in the process as possible.
+const config = require('config').util.toObject()
 const Raven = require('raven')
-const serverSecrets = require('./lib/server-secrets')
-
-Raven.config(process.env.SENTRY_DSN || serverSecrets.sentry_dsn).install()
+Raven.config(process.env.SENTRY_DSN || config.private.sentry_dsn).install()
 Raven.disableConsoleAlerts()
 
-const config = require('config').util.toObject()
 if (+process.argv[2]) {
   config.public.bind.port = +process.argv[2]
 }
@@ -21,6 +22,14 @@ if (process.argv[3]) {
 console.log('Configuration:')
 console.dir(config.public, { depth: null })
 
+const legacySecretsPath = path.join(__dirname, 'private', 'secret.json')
+if (fs.existsSync(legacySecretsPath)) {
+  console.error(
+    `Legacy secrets file found at ${legacySecretsPath}. It should be deleted and secrets replaced with environment variables or config/local.yml`
+  )
+  process.exit(1)
+}
+
 const Server = require('./core/server/server')
 const server = (module.exports = new Server(config))
 
diff --git a/services/azure-devops/azure-devops-base.js b/services/azure-devops/azure-devops-base.js
index 803156c6c66deda126387bb1fe95bb29f82f1464..f6d8b13fb3f4655534b88b96a0a7d85762055253 100644
--- a/services/azure-devops/azure-devops-base.js
+++ b/services/azure-devops/azure-devops-base.js
@@ -15,6 +15,10 @@ const latestBuildSchema = Joi.object({
 }).required()
 
 module.exports = class AzureDevOpsBase extends BaseJsonService {
+  static get auth() {
+    return { passKey: 'azure_devops_token' }
+  }
+
   async fetch({ url, options, schema, errorMessages }) {
     return this._requestJson({
       schema,
@@ -29,7 +33,7 @@ module.exports = class AzureDevOpsBase extends BaseJsonService {
     project,
     definitionId,
     branch,
-    headers,
+    auth,
     errorMessages
   ) {
     // Microsoft documentation: https://docs.microsoft.com/en-us/rest/api/azure/devops/build/builds/list?view=azure-devops-rest-5.0
@@ -41,7 +45,7 @@ module.exports = class AzureDevOpsBase extends BaseJsonService {
         statusFilter: 'completed',
         'api-version': '5.0-preview.4',
       },
-      headers,
+      auth,
     }
 
     if (branch) {
diff --git a/services/azure-devops/azure-devops-coverage.service.js b/services/azure-devops/azure-devops-coverage.service.js
index 9a72c5b1ff4853c08c81a388969bbd4546834fae..c084b5c6fe2323428b30cc871840811be52615a8 100644
--- a/services/azure-devops/azure-devops-coverage.service.js
+++ b/services/azure-devops/azure-devops-coverage.service.js
@@ -5,7 +5,7 @@ const {
   coveragePercentage: coveragePercentageColor,
 } = require('../color-formatters')
 const AzureDevOpsBase = require('./azure-devops-base')
-const { keywords, getHeaders } = require('./azure-devops-helpers')
+const { keywords } = require('./azure-devops-helpers')
 
 const documentation = `
 <p>
@@ -100,7 +100,7 @@ module.exports = class AzureDevOpsCoverage extends AzureDevOpsBase {
   }
 
   async handle({ organization, project, definitionId, branch }) {
-    const headers = getHeaders()
+    const auth = this.authHelper.basicAuth
     const errorMessages = {
       404: 'build pipeline or coverage not found',
     }
@@ -109,7 +109,7 @@ module.exports = class AzureDevOpsCoverage extends AzureDevOpsBase {
       project,
       definitionId,
       branch,
-      headers,
+      auth,
       errorMessages
     )
     // Microsoft documentation: https://docs.microsoft.com/en-us/rest/api/azure/devops/test/code%20coverage/get%20build%20code%20coverage?view=azure-devops-rest-5.0
@@ -119,7 +119,7 @@ module.exports = class AzureDevOpsCoverage extends AzureDevOpsBase {
         buildId,
         'api-version': '5.0-preview.1',
       },
-      headers,
+      auth,
     }
     const json = await this.fetch({
       url,
diff --git a/services/azure-devops/azure-devops-helpers.js b/services/azure-devops/azure-devops-helpers.js
index 2bfbc66d38e29a6e4160157644a1a1f0c24a4300..a05dabcdfbb99223a8f474114c4616c45f5e1a7d 100644
--- a/services/azure-devops/azure-devops-helpers.js
+++ b/services/azure-devops/azure-devops-helpers.js
@@ -1,7 +1,6 @@
 'use strict'
 
 const Joi = require('@hapi/joi')
-const serverSecrets = require('../../lib/server-secrets')
 const { isBuildStatus } = require('../build-status')
 
 const keywords = ['vso', 'vsts', 'azure-devops']
@@ -23,15 +22,4 @@ async function fetch(serviceInstance, { url, qs = {}, errorMessages }) {
   return { status }
 }
 
-function getHeaders() {
-  const headers = {}
-  if (serverSecrets.azure_devops_token) {
-    const pat = serverSecrets.azure_devops_token
-    const auth = Buffer.from(`:${pat}`).toString('base64')
-    headers.Authorization = `basic ${auth}`
-  }
-
-  return headers
-}
-
-module.exports = { keywords, fetch, getHeaders }
+module.exports = { keywords, fetch }
diff --git a/services/azure-devops/azure-devops-tests.service.js b/services/azure-devops/azure-devops-tests.service.js
index e1b9623f68c75db7e265502bbe7661e014297e24..e0ee802cb296e060e3ba63a7c077d65196aa8782 100644
--- a/services/azure-devops/azure-devops-tests.service.js
+++ b/services/azure-devops/azure-devops-tests.service.js
@@ -6,7 +6,6 @@ const {
   renderTestResultBadge,
 } = require('../test-results')
 const AzureDevOpsBase = require('./azure-devops-base')
-const { getHeaders } = require('./azure-devops-helpers')
 
 const commonAttrs = {
   keywords: ['vso', 'vsts', 'azure-devops'],
@@ -192,7 +191,7 @@ module.exports = class AzureDevOpsTests extends AzureDevOpsBase {
       skipped_label: skippedLabel,
     }
   ) {
-    const headers = getHeaders()
+    const auth = this.authHelper.basicAuth
     const errorMessages = {
       404: 'build pipeline or test result summary not found',
     }
@@ -201,7 +200,7 @@ module.exports = class AzureDevOpsTests extends AzureDevOpsBase {
       project,
       definitionId,
       branch,
-      headers,
+      auth,
       errorMessages
     )
 
@@ -211,7 +210,7 @@ module.exports = class AzureDevOpsTests extends AzureDevOpsBase {
       url: `https://dev.azure.com/${organization}/${project}/_apis/test/ResultSummaryByBuild`,
       options: {
         qs: { buildId },
-        headers,
+        auth,
       },
       schema: buildTestResultSummarySchema,
       errorMessages,
diff --git a/services/bintray/bintray.service.js b/services/bintray/bintray.service.js
index 09e3940558536bc9af9743307f5c427c1597b13d..a32cc34de40d3a24c050a50fa286625551f8da09 100644
--- a/services/bintray/bintray.service.js
+++ b/services/bintray/bintray.service.js
@@ -2,7 +2,6 @@
 
 const Joi = require('@hapi/joi')
 const { renderVersionBadge } = require('../version')
-const serverSecrets = require('../../lib/server-secrets')
 const { BaseJsonService } = require('..')
 
 const schema = Joi.object()
@@ -23,6 +22,10 @@ module.exports = class Bintray extends BaseJsonService {
     }
   }
 
+  static get auth() {
+    return { userKey: 'bintray_user', passKey: 'bintray_apikey' }
+  }
+
   static get examples() {
     return [
       {
@@ -42,19 +45,11 @@ module.exports = class Bintray extends BaseJsonService {
   }
 
   async fetch({ subject, repo, packageName }) {
-    const options = {}
-    if (serverSecrets.bintray_user) {
-      options.auth = {
-        user: serverSecrets.bintray_user,
-        pass: serverSecrets.bintray_apikey,
-      }
-    }
-
     // https://bintray.com/docs/api/#_get_version
     return this._requestJson({
       schema,
       url: `https://bintray.com/api/v1/packages/${subject}/${repo}/${packageName}/versions/_latest`,
-      options,
+      options: { auth: this.authHelper.basicAuth },
     })
   }
 
diff --git a/services/drone/drone-build.service.js b/services/drone/drone-build.service.js
index 3375fed785a2b3896a63ebf8537342c165bd5bd5..2d2b005fd4c9646791af03da428dbb444f49d7b6 100644
--- a/services/drone/drone-build.service.js
+++ b/services/drone/drone-build.service.js
@@ -1,7 +1,6 @@
 'use strict'
 
 const Joi = require('@hapi/joi')
-const serverSecrets = require('../../lib/server-secrets')
 const { isBuildStatus, renderBuildStatusBadge } = require('../build-status')
 const { optionalUrl } = require('../validators')
 const { BaseJsonService } = require('..')
@@ -29,6 +28,10 @@ module.exports = class DroneBuild extends BaseJsonService {
     }
   }
 
+  static get auth() {
+    return { passKey: 'drone_token' }
+  }
+
   static get examples() {
     return [
       {
@@ -85,11 +88,7 @@ module.exports = class DroneBuild extends BaseJsonService {
       qs: {
         ref: branch ? `refs/heads/${branch}` : undefined,
       },
-    }
-    if (serverSecrets.drone_token) {
-      options.headers = {
-        Authorization: `Bearer ${serverSecrets.drone_token}`,
-      }
+      headers: this.authHelper.bearerAuthHeader,
     }
     if (!server) {
       server = 'https://cloud.drone.io'
diff --git a/services/drone/drone-build.spec.js b/services/drone/drone-build.spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..41537d050ab730e18e6627fa0f60ee6522636160
--- /dev/null
+++ b/services/drone/drone-build.spec.js
@@ -0,0 +1,36 @@
+'use strict'
+
+const { expect } = require('chai')
+const nock = require('nock')
+const { cleanUpNockAfterEach, defaultContext } = require('../test-helpers')
+const DroneBuild = require('./drone-build.service')
+
+describe('DroneBuild', function() {
+  cleanUpNockAfterEach()
+
+  it('Sends auth headers to cloud instance', async function() {
+    const token = 'abc123'
+
+    const scope = nock('https://cloud.drone.io', {
+      reqheaders: { Authorization: `Bearer abc123` },
+    })
+      .get(/.*/)
+      .reply(200, { status: 'passing' })
+
+    expect(
+      await DroneBuild.invoke(
+        defaultContext,
+        {
+          private: { drone_token: token },
+        },
+        { user: 'atlassian', repo: 'python-bitbucket' }
+      )
+    ).to.deep.equal({
+      label: undefined,
+      message: 'passing',
+      color: 'brightgreen',
+    })
+
+    scope.done()
+  })
+})
diff --git a/services/drone/drone-build.tester.js b/services/drone/drone-build.tester.js
index 9a9f3e07da50de50073d3d01da1dcc1f15a63668..18046723c7a0587bffdc7a02e98e61bfab1726d2 100644
--- a/services/drone/drone-build.tester.js
+++ b/services/drone/drone-build.tester.js
@@ -3,7 +3,6 @@
 const Joi = require('@hapi/joi')
 const { isBuildStatus } = require('../build-status')
 const t = (module.exports = require('../tester').createServiceTester())
-const { mockDroneCreds, token, restore } = require('./drone-test-helpers')
 
 t.create('cloud-hosted build status on default branch')
   .get('/drone/drone.json')
@@ -27,35 +26,27 @@ t.create('cloud-hosted build status on unknown repo')
   })
 
 t.create('self-hosted build status on default branch')
-  .before(mockDroneCreds)
   .get('/badges/shields.json?server=https://drone.shields.io')
   .intercept(nock =>
-    nock('https://drone.shields.io/api/repos', {
-      reqheaders: { authorization: `Bearer ${token}` },
-    })
+    nock('https://drone.shields.io/api/repos')
       .get('/badges/shields/builds/latest')
       .reply(200, { status: 'success' })
   )
-  .finally(restore)
   .expectBadge({
     label: 'build',
     message: 'passing',
   })
 
 t.create('self-hosted build status on named branch')
-  .before(mockDroneCreds)
   .get(
     '/badges/shields/feat/awesome-thing.json?server=https://drone.shields.io'
   )
   .intercept(nock =>
-    nock('https://drone.shields.io/api/repos', {
-      reqheaders: { authorization: `Bearer ${token}` },
-    })
+    nock('https://drone.shields.io/api/repos')
       .get('/badges/shields/builds/latest')
       .query({ ref: 'refs/heads/feat/awesome-thing' })
       .reply(200, { status: 'success' })
   )
-  .finally(restore)
   .expectBadge({
     label: 'build',
     message: 'passing',
diff --git a/services/drone/drone-test-helpers.js b/services/drone/drone-test-helpers.js
deleted file mode 100644
index 8cc3c5b2c5838b56dba0e5f5141e980f1315b589..0000000000000000000000000000000000000000
--- a/services/drone/drone-test-helpers.js
+++ /dev/null
@@ -1,21 +0,0 @@
-'use strict'
-
-const sinon = require('sinon')
-const serverSecrets = require('../../lib/server-secrets')
-
-const token = 'my-token'
-
-function mockDroneCreds() {
-  serverSecrets['drone_token'] = undefined
-  sinon.stub(serverSecrets, 'drone_token').value(token)
-}
-
-function restore() {
-  sinon.restore()
-}
-
-module.exports = {
-  token,
-  mockDroneCreds,
-  restore,
-}
diff --git a/services/jenkins/jenkins-base.js b/services/jenkins/jenkins-base.js
index a74507438ff6b2a7665ac420bdca131db84938a5..58c9e682c987ae11d9df76e39a8cfbc38a747c3c 100644
--- a/services/jenkins/jenkins-base.js
+++ b/services/jenkins/jenkins-base.js
@@ -1,9 +1,15 @@
 'use strict'
 
-const serverSecrets = require('../../lib/server-secrets')
 const { BaseJsonService } = require('..')
 
 module.exports = class JenkinsBase extends BaseJsonService {
+  static get auth() {
+    return {
+      userKey: 'jenkins_user',
+      passKey: 'jenkins_pass',
+    }
+  }
+
   async fetch({
     url,
     schema,
@@ -11,18 +17,13 @@ module.exports = class JenkinsBase extends BaseJsonService {
     errorMessages = { 404: 'instance or job not found' },
     disableStrictSSL,
   }) {
-    const options = { qs, strictSSL: disableStrictSSL === undefined }
-
-    if (serverSecrets.jenkins_user) {
-      options.auth = {
-        user: serverSecrets.jenkins_user,
-        pass: serverSecrets.jenkins_pass,
-      }
-    }
-
     return this._requestJson({
       url,
-      options,
+      options: {
+        qs,
+        strictSSL: disableStrictSSL === undefined,
+        auth: this.authHelper.basicAuth,
+      },
       schema,
       errorMessages,
     })
diff --git a/services/jenkins/jenkins-build.spec.js b/services/jenkins/jenkins-build.spec.js
index a94054d4fee51f7b392085376cecb351a63e400c..32cb856518134ab938a36c7c0072c3349a015558 100644
--- a/services/jenkins/jenkins-build.spec.js
+++ b/services/jenkins/jenkins-build.spec.js
@@ -1,7 +1,10 @@
 'use strict'
 
+const { expect } = require('chai')
+const nock = require('nock')
 const { test, forCases, given } = require('sazerac')
 const { renderBuildStatusBadge } = require('../build-status')
+const { cleanUpNockAfterEach, defaultContext } = require('../test-helpers')
 const JenkinsBuild = require('./jenkins-build.service')
 
 describe('JenkinsBuild', function() {
@@ -54,4 +57,35 @@ describe('JenkinsBuild', function() {
       renderBuildStatusBadge({ status: 'not built' })
     )
   })
+
+  describe('auth', function() {
+    cleanUpNockAfterEach()
+
+    const user = 'admin'
+    const pass = 'password'
+    const config = { private: { jenkins_user: user, jenkins_pass: pass } }
+
+    it('sends the auth information as configured', async function() {
+      const scope = nock('https://jenkins.ubuntu.com')
+        .get('/server/job/curtin-vmtest-daily-x/api/json?tree=color')
+        // This ensures that the expected credentials are actually being sent with the HTTP request.
+        // Without this the request wouldn't match and the test would fail.
+        .basicAuth({ user, pass })
+        .reply(200, { color: 'blue' })
+
+      expect(
+        await JenkinsBuild.invoke(defaultContext, config, {
+          protocol: 'https',
+          host: 'jenkins.ubuntu.com',
+          job: 'server/job/curtin-vmtest-daily-x',
+        })
+      ).to.deep.equal({
+        label: undefined,
+        message: 'passing',
+        color: 'brightgreen',
+      })
+
+      scope.done()
+    })
+  })
 })
diff --git a/services/jenkins/jenkins-build.tester.js b/services/jenkins/jenkins-build.tester.js
index 424f10e264717c8aae61b80e110b54c01e2add9d..fb3dbff938811bec8e5810456ac026e83436edcf 100644
--- a/services/jenkins/jenkins-build.tester.js
+++ b/services/jenkins/jenkins-build.tester.js
@@ -1,8 +1,6 @@
 'use strict'
 
 const Joi = require('@hapi/joi')
-const sinon = require('sinon')
-const serverSecrets = require('../../lib/server-secrets')
 const { isBuildStatus } = require('../build-status')
 const t = (module.exports = require('../tester').createServiceTester())
 
@@ -22,30 +20,3 @@ t.create('build found (view)')
 t.create('build found (job)')
   .get('/https/ci.eclipse.org/jgit/job/jgit.json')
   .expectBadge({ label: 'build', message: isJenkinsBuildStatus })
-
-const user = 'admin'
-const pass = 'password'
-
-function mockCreds() {
-  serverSecrets['jenkins_user'] = undefined
-  serverSecrets['jenkins_pass'] = undefined
-  sinon.stub(serverSecrets, 'jenkins_user').value(user)
-  sinon.stub(serverSecrets, 'jenkins_pass').value(pass)
-}
-
-t.create('with mock credentials')
-  .before(mockCreds)
-  .get('/https/jenkins.ubuntu.com/server/job/curtin-vmtest-daily-x.json')
-  .intercept(nock =>
-    nock('https://jenkins.ubuntu.com/server/job/curtin-vmtest-daily-x')
-      .get(`/api/json?tree=color`)
-      // This ensures that the expected credentials from serverSecrets are actually being sent with the HTTP request.
-      // Without this the request wouldn't match and the test would fail.
-      .basicAuth({
-        user,
-        pass,
-      })
-      .reply(200, { color: 'blue' })
-  )
-  .finally(sinon.restore)
-  .expectBadge({ label: 'build', message: 'passing' })
diff --git a/services/jira/jira-base.js b/services/jira/jira-base.js
deleted file mode 100644
index 4f9e5f0d2c5ba948fa16f25202ef399e3eb9b64f..0000000000000000000000000000000000000000
--- a/services/jira/jira-base.js
+++ /dev/null
@@ -1,28 +0,0 @@
-'use strict'
-
-const serverSecrets = require('../../lib/server-secrets')
-const { BaseJsonService } = require('..')
-
-module.exports = class JiraBase extends BaseJsonService {
-  static get category() {
-    return 'issue-tracking'
-  }
-
-  async fetch({ url, qs, schema, errorMessages }) {
-    const options = { qs }
-
-    if (serverSecrets.jira_user) {
-      options.auth = {
-        user: serverSecrets.jira_user,
-        pass: serverSecrets.jira_pass,
-      }
-    }
-
-    return this._requestJson({
-      schema,
-      url,
-      options,
-      errorMessages,
-    })
-  }
-}
diff --git a/services/jira/jira-common.js b/services/jira/jira-common.js
new file mode 100644
index 0000000000000000000000000000000000000000..9f51ce3fd683409015258ac6d34afe74886c8c98
--- /dev/null
+++ b/services/jira/jira-common.js
@@ -0,0 +1,8 @@
+'use strict'
+
+const authConfig = {
+  userKey: 'jira_user',
+  passKey: 'jira_pass',
+}
+
+module.exports = { authConfig }
diff --git a/services/jira/jira-issue.service.js b/services/jira/jira-issue.service.js
index fe95067f418ca675956b1a79a45fe8a9704e7f91..f903f4e6f56ae5daecfd955a441dc1d49b2bdc7b 100644
--- a/services/jira/jira-issue.service.js
+++ b/services/jira/jira-issue.service.js
@@ -1,7 +1,8 @@
 'use strict'
 
 const Joi = require('@hapi/joi')
-const JiraBase = require('./jira-base')
+const { authConfig } = require('./jira-common')
+const { BaseJsonService } = require('..')
 
 const schema = Joi.object({
   fields: Joi.object({
@@ -14,7 +15,11 @@ const schema = Joi.object({
   }).required(),
 }).required()
 
-module.exports = class JiraIssue extends JiraBase {
+module.exports = class JiraIssue extends BaseJsonService {
+  static get category() {
+    return 'issue-tracking'
+  }
+
   static get route() {
     return {
       base: 'jira/issue',
@@ -22,6 +27,10 @@ module.exports = class JiraIssue extends JiraBase {
     }
   }
 
+  static get auth() {
+    return authConfig
+  }
+
   static get examples() {
     return [
       {
@@ -68,16 +77,15 @@ module.exports = class JiraIssue extends JiraBase {
 
   async handle({ protocol, hostAndPath, issueKey }) {
     // Atlassian Documentation: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-api-2-issue-issueIdOrKey-get
-    const url = `${protocol}://${hostAndPath}/rest/api/2/issue/${encodeURIComponent(
-      issueKey
-    )}`
-    const json = await this.fetch({
-      url,
+    const json = await this._requestJson({
       schema,
-      errorMessages: {
-        404: 'issue not found',
-      },
+      url: `${protocol}://${hostAndPath}/rest/api/2/issue/${encodeURIComponent(
+        issueKey
+      )}`,
+      options: { auth: this.authHelper.basicAuth },
+      errorMessages: { 404: 'issue not found' },
     })
+
     const issueStatus = json.fields.status
     const statusName = issueStatus.name
     let statusColor
diff --git a/services/jira/jira-issue.spec.js b/services/jira/jira-issue.spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..bccde62ef7a2e6e43d54a0da1faa7e5baef387da
--- /dev/null
+++ b/services/jira/jira-issue.spec.js
@@ -0,0 +1,34 @@
+'use strict'
+
+const { expect } = require('chai')
+const nock = require('nock')
+const { cleanUpNockAfterEach, defaultContext } = require('../test-helpers')
+const JiraIssue = require('./jira-issue.service')
+const { user, pass, config } = require('./jira-test-helpers')
+
+describe('JiraIssue', function() {
+  cleanUpNockAfterEach()
+
+  it('sends the auth information as configured', async function() {
+    const scope = nock('https://myprivatejira.test')
+      .get(`/rest/api/2/issue/${encodeURIComponent('secure-234')}`)
+      // This ensures that the expected credentials are actually being sent with the HTTP request.
+      // Without this the request wouldn't match and the test would fail.
+      .basicAuth({ user, pass })
+      .reply(200, { fields: { status: { name: 'in progress' } } })
+
+    expect(
+      await JiraIssue.invoke(defaultContext, config, {
+        protocol: 'https',
+        hostAndPath: 'myprivatejira.test',
+        issueKey: 'secure-234',
+      })
+    ).to.deep.equal({
+      label: 'secure-234',
+      message: 'in progress',
+      color: 'lightgrey',
+    })
+
+    scope.done()
+  })
+})
diff --git a/services/jira/jira-issue.tester.js b/services/jira/jira-issue.tester.js
index 1729b9d2d3aba8938d5c2e92667ef50d2ad0fef1..edb6aab527f354e663a6a049a262aeadbec24363 100644
--- a/services/jira/jira-issue.tester.js
+++ b/services/jira/jira-issue.tester.js
@@ -1,13 +1,12 @@
 'use strict'
 
 const t = (module.exports = require('../tester').createServiceTester())
-const { mockJiraCreds, restore, user, pass } = require('./jira-test-helpers')
 
-t.create('live: unknown issue')
+t.create('unknown issue')
   .get('/https/issues.apache.org/jira/notArealIssue-000.json')
   .expectBadge({ label: 'jira', message: 'issue not found' })
 
-t.create('live: known issue')
+t.create('known issue')
   .get('/https/issues.apache.org/jira/kafka-2896.json')
   .expectBadge({ label: 'kafka-2896', message: 'Resolved' })
 
@@ -161,26 +160,3 @@ t.create('blue-gray status color')
     message: 'cloudy',
     color: 'blue',
   })
-
-t.create('with mock credentials')
-  .before(mockJiraCreds)
-  .get('/https/myprivatejira.com/secure-234.json')
-  .intercept(nock =>
-    nock('https://myprivatejira.com/rest/api/2/issue')
-      .get(`/${encodeURIComponent('secure-234')}`)
-      // This ensures that the expected credentials from serverSecrets are actually being sent with the HTTP request.
-      // Without this the request wouldn't match and the test would fail.
-      .basicAuth({
-        user,
-        pass,
-      })
-      .reply(200, {
-        fields: {
-          status: {
-            name: 'in progress',
-          },
-        },
-      })
-  )
-  .finally(restore)
-  .expectBadge({ label: 'secure-234', message: 'in progress' })
diff --git a/services/jira/jira-sprint.service.js b/services/jira/jira-sprint.service.js
index 7722477b44b58719fa1af61257554c820efa8b2a..f86b0d10a21ed628693339be72ce4cbc5ce7c360 100644
--- a/services/jira/jira-sprint.service.js
+++ b/services/jira/jira-sprint.service.js
@@ -1,7 +1,8 @@
 'use strict'
 
 const Joi = require('@hapi/joi')
-const JiraBase = require('./jira-base')
+const { authConfig } = require('./jira-common')
+const { BaseJsonService } = require('..')
 
 const schema = Joi.object({
   total: Joi.number(),
@@ -26,7 +27,11 @@ const documentation = `
 </p>
 `
 
-module.exports = class JiraSprint extends JiraBase {
+module.exports = class JiraSprint extends BaseJsonService {
+  static get category() {
+    return 'issue-tracking'
+  }
+
   static get route() {
     return {
       base: 'jira/sprint',
@@ -34,6 +39,10 @@ module.exports = class JiraSprint extends JiraBase {
     }
   }
 
+  static get auth() {
+    return authConfig
+  }
+
   static get examples() {
     return [
       {
@@ -79,21 +88,23 @@ module.exports = class JiraSprint extends JiraBase {
     // Atlassian Documentation: https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-group-Search
     // There are other sprint-specific APIs but those require authentication. The search API
     // allows us to get the needed data without being forced to authenticate.
-    const url = `${protocol}://${hostAndPath}/rest/api/2/search`
-    const qs = {
-      jql: `sprint=${sprintId} AND type IN (Bug,Improvement,Story,"Technical task")`,
-      fields: 'resolution',
-      maxResults: 500,
-    }
-    const json = await this.fetch({
-      url,
+    const json = await this._requestJson({
+      url: `${protocol}://${hostAndPath}/rest/api/2/search`,
       schema,
-      qs,
+      options: {
+        qs: {
+          jql: `sprint=${sprintId} AND type IN (Bug,Improvement,Story,"Technical task")`,
+          fields: 'resolution',
+          maxResults: 500,
+        },
+        auth: this.authHelper.basicAuth,
+      },
       errorMessages: {
         400: 'sprint not found',
         404: 'sprint not found',
       },
     })
+
     const numTotalIssues = json.total
     const numCompletedIssues = json.issues.filter(issue => {
       if (issue.fields.resolution != null) {
diff --git a/services/jira/jira-sprint.spec.js b/services/jira/jira-sprint.spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..a0d92653166f9be135036e19a034b7a3817592fd
--- /dev/null
+++ b/services/jira/jira-sprint.spec.js
@@ -0,0 +1,47 @@
+'use strict'
+
+const { expect } = require('chai')
+const nock = require('nock')
+const { cleanUpNockAfterEach, defaultContext } = require('../test-helpers')
+const JiraSprint = require('./jira-sprint.service')
+const {
+  user,
+  pass,
+  config,
+  sprintId,
+  sprintQueryString,
+} = require('./jira-test-helpers')
+
+describe('JiraSprint', function() {
+  cleanUpNockAfterEach()
+
+  it('sends the auth information as configured', async function() {
+    const scope = nock('https://myprivatejira.test')
+      .get('/jira/rest/api/2/search')
+      .query(sprintQueryString)
+      // This ensures that the expected credentials are actually being sent with the HTTP request.
+      // Without this the request wouldn't match and the test would fail.
+      .basicAuth({ user, pass })
+      .reply(200, {
+        total: 2,
+        issues: [
+          { fields: { resolution: { name: 'done' } } },
+          { fields: { resolution: { name: 'Unresolved' } } },
+        ],
+      })
+
+    expect(
+      await JiraSprint.invoke(defaultContext, config, {
+        protocol: 'https',
+        hostAndPath: 'myprivatejira.test/jira',
+        sprintId,
+      })
+    ).to.deep.equal({
+      label: 'completion',
+      message: '50%',
+      color: 'orange',
+    })
+
+    scope.done()
+  })
+})
diff --git a/services/jira/jira-sprint.tester.js b/services/jira/jira-sprint.tester.js
index 7813afefe6d9a9d4a95388421be97f96c8bf842a..faa8e72c5f48948a382293e6e1acd2135f471555 100644
--- a/services/jira/jira-sprint.tester.js
+++ b/services/jira/jira-sprint.tester.js
@@ -2,20 +2,13 @@
 
 const t = (module.exports = require('../tester').createServiceTester())
 const { isIntegerPercentage } = require('../test-validators')
-const { mockJiraCreds, restore, user, pass } = require('./jira-test-helpers')
+const { sprintId, sprintQueryString } = require('./jira-test-helpers')
 
-const sprintId = 8
-const queryString = {
-  jql: `sprint=${sprintId} AND type IN (Bug,Improvement,Story,"Technical task")`,
-  fields: 'resolution',
-  maxResults: 500,
-}
-
-t.create('live: unknown sprint')
+t.create('unknown sprint')
   .get('/https/jira.spring.io/abc.json')
   .expectBadge({ label: 'jira', message: 'sprint not found' })
 
-t.create('live: known sprint')
+t.create('known sprint')
   .get('/https/jira.spring.io/94.json')
   .expectBadge({
     label: 'completion',
@@ -27,7 +20,7 @@ t.create('100% completion')
   .intercept(nock =>
     nock('http://issues.apache.org/jira/rest/api/2')
       .get('/search')
-      .query(queryString)
+      .query(sprintQueryString)
       .reply(200, {
         total: 2,
         issues: [
@@ -59,7 +52,7 @@ t.create('0% completion')
   .intercept(nock =>
     nock('http://issues.apache.org/jira/rest/api/2')
       .get('/search')
-      .query(queryString)
+      .query(sprintQueryString)
       .reply(200, {
         total: 1,
         issues: [
@@ -84,7 +77,7 @@ t.create('no issues in sprint')
   .intercept(nock =>
     nock('http://issues.apache.org/jira/rest/api/2')
       .get('/search')
-      .query(queryString)
+      .query(sprintQueryString)
       .reply(200, {
         total: 0,
         issues: [],
@@ -101,7 +94,7 @@ t.create('issue with null resolution value')
   .intercept(nock =>
     nock('https://jira.spring.io:8080/rest/api/2')
       .get('/search')
-      .query(queryString)
+      .query(sprintQueryString)
       .reply(200, {
         total: 2,
         issues: [
@@ -125,39 +118,3 @@ t.create('issue with null resolution value')
     message: '50%',
     color: 'orange',
   })
-
-t.create('with mock credentials')
-  .before(mockJiraCreds)
-  .get(`/https/myprivatejira/jira/${sprintId}.json`)
-  .intercept(nock =>
-    nock('https://myprivatejira/jira/rest/api/2')
-      .get('/search')
-      .query(queryString)
-      // This ensures that the expected credentials from serverSecrets are actually being sent with the HTTP request.
-      // Without this the request wouldn't match and the test would fail.
-      .basicAuth({
-        user,
-        pass,
-      })
-      .reply(200, {
-        total: 2,
-        issues: [
-          {
-            fields: {
-              resolution: {
-                name: 'done',
-              },
-            },
-          },
-          {
-            fields: {
-              resolution: {
-                name: 'Unresolved',
-              },
-            },
-          },
-        ],
-      })
-  )
-  .finally(restore)
-  .expectBadge({ label: 'completion', message: '50%' })
diff --git a/services/jira/jira-test-helpers.js b/services/jira/jira-test-helpers.js
index 291f84dfaea21dee30af9e4a719974e1bf778c33..ad31e8d2286c3346f114c980714db6ca08696b41 100644
--- a/services/jira/jira-test-helpers.js
+++ b/services/jira/jira-test-helpers.js
@@ -1,25 +1,20 @@
 'use strict'
 
-const sinon = require('sinon')
-const serverSecrets = require('../../lib/server-secrets')
+const sprintId = 8
+const sprintQueryString = {
+  jql: `sprint=${sprintId} AND type IN (Bug,Improvement,Story,"Technical task")`,
+  fields: 'resolution',
+  maxResults: 500,
+}
 
 const user = 'admin'
 const pass = 'password'
-
-function mockJiraCreds() {
-  serverSecrets['jira_user'] = undefined
-  serverSecrets['jira_pass'] = undefined
-  sinon.stub(serverSecrets, 'jira_user').value(user)
-  sinon.stub(serverSecrets, 'jira_pass').value(pass)
-}
-
-function restore() {
-  sinon.restore()
-}
+const config = { private: { jira_user: user, jira_pass: pass } }
 
 module.exports = {
+  sprintId,
+  sprintQueryString,
   user,
   pass,
-  mockJiraCreds,
-  restore,
+  config,
 }
diff --git a/services/nexus/nexus.service.js b/services/nexus/nexus.service.js
index 8f1c506b6e0c39104a15ed82031c2befaa96b9d7..9f540f5a44ea07b44d179262eec6af894a652c4c 100644
--- a/services/nexus/nexus.service.js
+++ b/services/nexus/nexus.service.js
@@ -3,7 +3,6 @@
 const Joi = require('@hapi/joi')
 const { version: versionColor } = require('../color-formatters')
 const { addv } = require('../text-formatters')
-const serverSecrets = require('../../lib/server-secrets')
 const {
   optionalDottedVersionNClausesWithOptionalSuffix,
 } = require('../validators')
@@ -49,6 +48,10 @@ module.exports = class Nexus extends BaseJsonService {
     }
   }
 
+  static get auth() {
+    return { userKey: 'nexus_user', passKey: 'nexus_pass' }
+  }
+
   static get examples() {
     return [
       {
@@ -167,19 +170,10 @@ module.exports = class Nexus extends BaseJsonService {
       this.addQueryParamsToQueryString({ qs, queryOpt })
     }
 
-    const options = { qs }
-
-    if (serverSecrets.nexus_user) {
-      options.auth = {
-        user: serverSecrets.nexus_user,
-        pass: serverSecrets.nexus_pass,
-      }
-    }
-
     const json = await this._requestJson({
       schema,
       url,
-      options,
+      options: { qs, auth: this.authHelper.basicAuth },
       errorMessages: {
         404: 'artifact not found',
       },
diff --git a/services/nexus/nexus.spec.js b/services/nexus/nexus.spec.js
index 92e5c91382e846bb970d196493041ecb586659f1..1cca34fed0a79c4d1cfa115e06c93be9d1146356 100644
--- a/services/nexus/nexus.spec.js
+++ b/services/nexus/nexus.spec.js
@@ -1,6 +1,8 @@
 'use strict'
 
 const { expect } = require('chai')
+const nock = require('nock')
+const { cleanUpNockAfterEach, defaultContext } = require('../test-helpers')
 const Nexus = require('./nexus.service')
 const { InvalidResponse, NotFound } = require('..')
 
@@ -68,4 +70,37 @@ describe('Nexus', function() {
       }
     })
   })
+
+  describe('auth', function() {
+    cleanUpNockAfterEach()
+
+    const user = 'admin'
+    const pass = 'password'
+    const config = { private: { nexus_user: user, nexus_pass: pass } }
+
+    it('sends the auth information as configured', async function() {
+      const scope = nock('https://repository.jboss.org/nexus')
+        .get('/service/local/lucene/search')
+        .query({ g: 'jboss', a: 'jboss-client' })
+        // This ensures that the expected credentials are actually being sent with the HTTP request.
+        // Without this the request wouldn't match and the test would fail.
+        .basicAuth({ user, pass })
+        .reply(200, { data: [{ latestRelease: '2.3.4' }] })
+
+      expect(
+        await Nexus.invoke(defaultContext, config, {
+          repo: 'r',
+          scheme: 'https',
+          hostAndPath: 'repository.jboss.org/nexus',
+          groupId: 'jboss',
+          artifactId: 'jboss-client',
+        })
+      ).to.deep.equal({
+        message: 'v2.3.4',
+        color: 'blue',
+      })
+
+      scope.done()
+    })
+  })
 })
diff --git a/services/nexus/nexus.tester.js b/services/nexus/nexus.tester.js
index 3b4ea29a93e87bca0ba5b5641852aae712fc7b5f..5d9df348adb9bb36629607e7e3085c749f547bc5 100644
--- a/services/nexus/nexus.tester.js
+++ b/services/nexus/nexus.tester.js
@@ -1,23 +1,11 @@
 'use strict'
 
-const sinon = require('sinon')
 const {
   isVPlusDottedVersionNClausesWithOptionalSuffix: isVersion,
 } = require('../test-validators')
 const t = (module.exports = require('../tester').createServiceTester())
-const serverSecrets = require('../../lib/server-secrets')
 
-const user = 'admin'
-const pass = 'password'
-
-function mockNexusCreds() {
-  serverSecrets['nexus_user'] = undefined
-  serverSecrets['nexus_pass'] = undefined
-  sinon.stub(serverSecrets, 'nexus_user').value(user)
-  sinon.stub(serverSecrets, 'nexus_pass').value(pass)
-}
-
-t.create('live: search release version valid artifact')
+t.create('search release version valid artifact')
   .timeout(15000)
   .get('/r/https/oss.sonatype.org/com.google.guava/guava.json')
   .expectBadge({
@@ -25,7 +13,7 @@ t.create('live: search release version valid artifact')
     message: isVersion,
   })
 
-t.create('live: search release version of an nonexistent artifact')
+t.create('search release version of an nonexistent artifact')
   .timeout(10000)
   .get(
     '/r/https/oss.sonatype.org/com.google.guava/nonexistent-artifact-id.json'
@@ -35,7 +23,7 @@ t.create('live: search release version of an nonexistent artifact')
     message: 'artifact or version not found',
   })
 
-t.create('live: search snapshot version valid snapshot artifact')
+t.create('search snapshot version valid snapshot artifact')
   .timeout(10000)
   .get('/s/https/oss.sonatype.org/com.google.guava/guava.json')
   .expectBadge({
@@ -43,7 +31,7 @@ t.create('live: search snapshot version valid snapshot artifact')
     message: isVersion,
   })
 
-t.create('live: search snapshot version of an nonexistent artifact')
+t.create('search snapshot version of an nonexistent artifact')
   .timeout(10000)
   .get(
     '/s/https/oss.sonatype.org/com.google.guava/nonexistent-artifact-id.json'
@@ -54,14 +42,14 @@ t.create('live: search snapshot version of an nonexistent artifact')
     color: 'red',
   })
 
-t.create('live: repository version')
+t.create('repository version')
   .get('/developer/https/repository.jboss.org/nexus/ai.h2o/h2o-automl.json')
   .expectBadge({
     label: 'nexus',
     message: isVersion,
   })
 
-t.create('live: repository version with query')
+t.create('repository version with query')
   .get(
     '/fs-public-snapshots/https/repository.jboss.org/nexus/com.progress.fuse/fusehq:c=agent-apple-osx:p=tar.gz.json'
   )
@@ -70,7 +58,7 @@ t.create('live: repository version with query')
     message: isVersion,
   })
 
-t.create('live: repository version of an nonexistent artifact')
+t.create('repository version of an nonexistent artifact')
   .get(
     '/developer/https/repository.jboss.org/nexus/jboss/nonexistent-artifact-id.json'
   )
@@ -208,25 +196,3 @@ t.create('user query params')
     message: 'v3.2.1',
     color: 'blue',
   })
-
-t.create('auth')
-  .before(mockNexusCreds)
-  .get('/r/https/repository.jboss.org/nexus/jboss/jboss-client.json')
-  .intercept(nock =>
-    nock('https://repository.jboss.org/nexus')
-      .get('/service/local/lucene/search')
-      .query({ g: 'jboss', a: 'jboss-client' })
-      // This ensures that the expected credentials from serverSecrets are actually being sent with the HTTP request.
-      // Without this the request wouldn't match and the test would fail.
-      .basicAuth({
-        user,
-        pass,
-      })
-      .reply(200, { data: [{ latestRelease: '2.3.4' }] })
-  )
-  .finally(sinon.restore)
-  .expectBadge({
-    label: 'nexus',
-    message: 'v2.3.4',
-    color: 'blue',
-  })
diff --git a/services/npm/npm-base.js b/services/npm/npm-base.js
index 829eb1af4c10021de0e9e9cdfced945db33c3a38..d70a307fdb3b475dd8aaf626aca2deacc0316662 100644
--- a/services/npm/npm-base.js
+++ b/services/npm/npm-base.js
@@ -1,7 +1,6 @@
 'use strict'
 
 const Joi = require('@hapi/joi')
-const serverSecrets = require('../../lib/server-secrets')
 const { optionalUrl } = require('../validators')
 const { isDependencyMap } = require('../package-json-helpers')
 const { BaseJsonService, InvalidResponse, NotFound } = require('..')
@@ -38,6 +37,10 @@ const queryParamSchema = Joi.object({
 // Abstract class for NPM badges which display data about the latest version
 // of a package.
 module.exports = class NpmBase extends BaseJsonService {
+  static get auth() {
+    return { passKey: 'npm_token' }
+  }
+
   static buildRoute(base, { withTag } = {}) {
     if (withTag) {
       return {
@@ -74,15 +77,16 @@ module.exports = class NpmBase extends BaseJsonService {
   }
 
   async _requestJson(data) {
-    // Use a custom Accept header because of this bug:
-    // <https://github.com/npm/npmjs.org/issues/163>
-    const headers = { Accept: '*/*' }
-    if (serverSecrets.npm_token) {
-      headers.Authorization = `Bearer ${serverSecrets.npm_token}`
-    }
     return super._requestJson({
       ...data,
-      options: { headers },
+      options: {
+        headers: {
+          // Use a custom Accept header because of this bug:
+          // <https://github.com/npm/npmjs.org/issues/163>
+          Accept: '*/*',
+          ...this.authHelper.bearerAuthHeader,
+        },
+      },
     })
   }
 
diff --git a/services/sonar/sonar-base.js b/services/sonar/sonar-base.js
index e988995a5513ba59692f04484b4f5348f11095d7..001cd1377f3f179a7a5c1375211128bb97d765f1 100644
--- a/services/sonar/sonar-base.js
+++ b/services/sonar/sonar-base.js
@@ -1,11 +1,10 @@
 'use strict'
 
 const Joi = require('@hapi/joi')
-const serverSecrets = require('../../lib/server-secrets')
 const { isLegacyVersion } = require('./sonar-helpers')
 const { BaseJsonService } = require('..')
 
-const schema = Joi.object({
+const modernSchema = Joi.object({
   component: Joi.object({
     measures: Joi.array()
       .items(
@@ -21,7 +20,7 @@ const schema = Joi.object({
   }).required(),
 }).required()
 
-const legacyApiSchema = Joi.array()
+const legacySchema = Joi.array()
   .items(
     Joi.object({
       msr: Joi.array()
@@ -40,11 +39,16 @@ const legacyApiSchema = Joi.array()
   .required()
 
 module.exports = class SonarBase extends BaseJsonService {
+  static get auth() {
+    return { userKey: 'sonarqube_token' }
+  }
+
   async fetch({ sonarVersion, protocol, host, component, metricName }) {
-    let qs, url
+    let qs, url, schema
     const useLegacyApi = isLegacyVersion({ sonarVersion })
 
     if (useLegacyApi) {
+      schema = legacySchema
       url = `${protocol}://${host}/api/resources`
       qs = {
         resource: component,
@@ -53,6 +57,7 @@ module.exports = class SonarBase extends BaseJsonService {
         includeTrends: true,
       }
     } else {
+      schema = modernSchema
       url = `${protocol}://${host}/api/measures/component`
       qs = {
         componentKey: component,
@@ -60,18 +65,13 @@ module.exports = class SonarBase extends BaseJsonService {
       }
     }
 
-    const options = { qs }
-
-    if (serverSecrets.sonarqube_token) {
-      options.auth = {
-        user: serverSecrets.sonarqube_token,
-      }
-    }
-
     return this._requestJson({
-      schema: useLegacyApi ? legacyApiSchema : schema,
+      schema,
       url,
-      options,
+      options: {
+        qs,
+        auth: this.authHelper.basicAuth,
+      },
       errorMessages: {
         404: 'component or metric not found, or legacy API not supported',
       },
diff --git a/services/sonar/sonar-fortify-rating.spec.js b/services/sonar/sonar-fortify-rating.spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..c935d627dda56afd80d9201a3679e7d04811719b
--- /dev/null
+++ b/services/sonar/sonar-fortify-rating.spec.js
@@ -0,0 +1,43 @@
+'use strict'
+
+const { expect } = require('chai')
+const nock = require('nock')
+const { cleanUpNockAfterEach, defaultContext } = require('../test-helpers')
+const SonarFortifyRating = require('./sonar-fortify-rating.service')
+
+const token = 'abc123def456'
+const config = { private: { sonarqube_token: token } }
+
+describe('SonarFortifyRating', function() {
+  cleanUpNockAfterEach()
+
+  it('sends the auth information as configured', async function() {
+    const scope = nock('http://sonar.petalslink.com')
+      .get('/api/measures/component')
+      .query({
+        componentKey: 'org.ow2.petals:petals-se-ase',
+        metricKeys: 'fortify-security-rating',
+      })
+      // This ensures that the expected credentials are actually being sent with the HTTP request.
+      // Without this the request wouldn't match and the test would fail.
+      .basicAuth({ user: token })
+      .reply(200, {
+        component: {
+          measures: [{ metric: 'fortify-security-rating', value: 4 }],
+        },
+      })
+
+    expect(
+      await SonarFortifyRating.invoke(defaultContext, config, {
+        protocol: 'http',
+        host: 'sonar.petalslink.com',
+        component: 'org.ow2.petals:petals-se-ase',
+      })
+    ).to.deep.equal({
+      color: 'green',
+      message: '4/5',
+    })
+
+    scope.done()
+  })
+})
diff --git a/services/sonar/sonar-fortify-rating.tester.js b/services/sonar/sonar-fortify-rating.tester.js
index dad09132c80c13418a1cfedeedfe4e325b37315b..bef8026f65a35c283687e77b89b3e0497d993997 100644
--- a/services/sonar/sonar-fortify-rating.tester.js
+++ b/services/sonar/sonar-fortify-rating.tester.js
@@ -1,19 +1,12 @@
 'use strict'
 
-const sinon = require('sinon')
 const t = (module.exports = require('../tester').createServiceTester())
-const serverSecrets = require('../../lib/server-secrets')
-const sonarToken = 'abc123def456'
 
 // The below tests are using a mocked API response because
 // neither SonarCloud.io nor any known public SonarQube deployments
 // have the Fortify plugin installed and in use, so there are no
 // available live endpoints to hit.
 t.create('Fortify Security Rating')
-  .before(() => {
-    serverSecrets['sonarqube_token'] = undefined
-    sinon.stub(serverSecrets, 'sonarqube_token').value(sonarToken)
-  })
   .get(
     '/http/sonar.petalslink.com/org.ow2.petals%3Apetals-se-ase/fortify-security-rating.json'
   )
@@ -24,7 +17,6 @@ t.create('Fortify Security Rating')
         componentKey: 'org.ow2.petals:petals-se-ase',
         metricKeys: 'fortify-security-rating',
       })
-      .basicAuth({ user: sonarToken })
       .reply(200, {
         component: {
           measures: [
@@ -36,7 +28,6 @@ t.create('Fortify Security Rating')
         },
       })
   )
-  .finally(sinon.restore)
   .expectBadge({
     label: 'fortify-security-rating',
     message: '4/5',
diff --git a/services/symfony/symfony-insight-base.js b/services/symfony/symfony-insight-base.js
index 0150cd2e5567cddbfd1087b59a24ebe0b1d9ca30..a51f7fffe0afcaeb764aecbb17187098a73f31e7 100644
--- a/services/symfony/symfony-insight-base.js
+++ b/services/symfony/symfony-insight-base.js
@@ -1,8 +1,7 @@
 'use strict'
 
 const Joi = require('@hapi/joi')
-const serverSecrets = require('../../lib/server-secrets')
-const { BaseXmlService, Inaccessible } = require('..')
+const { BaseXmlService } = require('..')
 
 const violationSchema = Joi.object({
   severity: Joi.equal('info', 'minor', 'major', 'critical').required(),
@@ -40,10 +39,10 @@ const keywords = ['sensiolabs', 'sensio']
 
 const gradeColors = {
   none: 'red',
-  bronze: '#C88F6A',
-  silver: '#C0C0C0',
-  gold: '#EBC760',
-  platinum: '#E5E4E2',
+  bronze: '#c88f6a',
+  silver: '#c0c0c0',
+  gold: '#ebc760',
+  platinum: '#e5e4e2',
 }
 
 class SymfonyInsightBase extends BaseXmlService {
@@ -51,6 +50,14 @@ class SymfonyInsightBase extends BaseXmlService {
     return 'analysis'
   }
 
+  static get auth() {
+    return {
+      userKey: 'sl_insight_userUuid',
+      passKey: 'sl_insight_apiToken',
+      isRequired: true,
+    }
+  }
+
   static get defaultBadgeData() {
     return {
       label: 'symfony insight',
@@ -58,31 +65,15 @@ class SymfonyInsightBase extends BaseXmlService {
   }
 
   async fetch({ projectUuid }) {
-    const url = `https://insight.symfony.com/api/projects/${projectUuid}`
-    const options = {
-      headers: {
-        Accept: 'application/vnd.com.sensiolabs.insight+xml',
-      },
-    }
-
-    if (
-      !serverSecrets.sl_insight_userUuid ||
-      !serverSecrets.sl_insight_apiToken
-    ) {
-      throw new Inaccessible({
-        prettyMessage: 'required API tokens not found in config',
-      })
-    }
-
-    options.auth = {
-      user: serverSecrets.sl_insight_userUuid,
-      pass: serverSecrets.sl_insight_apiToken,
-    }
-
     return this._requestXml({
-      url,
-      options,
       schema,
+      url: `https://insight.symfony.com/api/projects/${projectUuid}`,
+      options: {
+        headers: {
+          Accept: 'application/vnd.com.sensiolabs.insight+xml',
+        },
+        auth: this.authHelper.basicAuth,
+      },
       errorMessages: {
         401: 'not authorized to access project',
         404: 'project not found',
diff --git a/services/symfony/symfony-insight-grade.tester.js b/services/symfony/symfony-insight-grade.tester.js
index 647f6a63a4a1c83e33fafa099e604e1e5c41ff95..678e4ab7c548c7e4eeff071134d3fc5e06a32901 100644
--- a/services/symfony/symfony-insight-grade.tester.js
+++ b/services/symfony/symfony-insight-grade.tester.js
@@ -2,30 +2,12 @@
 
 const Joi = require('@hapi/joi')
 const t = (module.exports = require('../tester').createServiceTester())
-const {
-  createTest,
-  runningMockResponse,
-  platinumMockResponse,
-  goldMockResponse,
-  silverMockResponse,
-  bronzeMockResponse,
-  noMedalMockResponse,
-  prepLiveTest,
-  sampleProjectUuid,
-  realTokenExists,
-  setSymfonyInsightCredsToFalsy,
-  restore,
-} = require('./symfony-test-helpers')
+const { sampleProjectUuid, checkShouldSkip } = require('./symfony-test-helpers')
 
-createTest(t, 'live: valid project grade', { withMockCreds: false })
-  .before(prepLiveTest)
+t.create('valid project grade')
+  .skipWhen(checkShouldSkip)
   .get(`/${sampleProjectUuid}.json`)
   .timeout(15000)
-  .interceptIf(!realTokenExists, nock =>
-    nock('https://insight.symfony.com/api/projects')
-      .get(`/${sampleProjectUuid}`)
-      .reply(200, platinumMockResponse)
-  )
   .expectBadge({
     label: 'grade',
     message: Joi.equal(
@@ -37,114 +19,10 @@ createTest(t, 'live: valid project grade', { withMockCreds: false })
     ).required(),
   })
 
-createTest(t, 'live: nonexistent project', { withMockCreds: false })
-  .before(prepLiveTest)
+t.create('nonexistent project')
+  .skipWhen(checkShouldSkip)
   .get('/45afb680-d4e6-4e66-93ea-bcfa79eb8a88.json')
-  .interceptIf(!realTokenExists, nock =>
-    nock('https://insight.symfony.com/api/projects')
-      .get('/45afb680-d4e6-4e66-93ea-bcfa79eb8a88')
-      .reply(404)
-  )
   .expectBadge({
     label: 'symfony insight',
     message: 'project not found',
   })
-
-createTest(t, '401 not authorized grade')
-  .get(`/${sampleProjectUuid}.json`)
-  .intercept(nock =>
-    nock('https://insight.symfony.com/api/projects')
-      .get(`/${sampleProjectUuid}`)
-      .reply(401)
-  )
-  .expectBadge({
-    label: 'symfony insight',
-    message: 'not authorized to access project',
-  })
-
-createTest(t, 'pending project grade')
-  .get(`/${sampleProjectUuid}.json`)
-  .intercept(nock =>
-    nock('https://insight.symfony.com/api/projects')
-      .get(`/${sampleProjectUuid}`)
-      .reply(200, runningMockResponse)
-  )
-  .expectBadge({
-    label: 'grade',
-    message: 'pending',
-    color: 'lightgrey',
-  })
-
-createTest(t, 'platinum grade')
-  .get(`/${sampleProjectUuid}.json`)
-  .intercept(nock =>
-    nock('https://insight.symfony.com/api/projects')
-      .get(`/${sampleProjectUuid}`)
-      .reply(200, platinumMockResponse)
-  )
-  .expectBadge({
-    label: 'grade',
-    message: 'platinum',
-    color: '#e5e4e2',
-  })
-
-createTest(t, 'gold grade')
-  .get(`/${sampleProjectUuid}.json`)
-  .intercept(nock =>
-    nock('https://insight.symfony.com/api/projects')
-      .get(`/${sampleProjectUuid}`)
-      .reply(200, goldMockResponse)
-  )
-  .expectBadge({
-    label: 'grade',
-    message: 'gold',
-    color: '#ebc760',
-  })
-
-createTest(t, 'silver grade')
-  .get(`/${sampleProjectUuid}.json`)
-  .intercept(nock =>
-    nock('https://insight.symfony.com/api/projects')
-      .get(`/${sampleProjectUuid}`)
-      .reply(200, silverMockResponse)
-  )
-  .expectBadge({
-    label: 'grade',
-    message: 'silver',
-    color: '#c0c0c0',
-  })
-
-createTest(t, 'bronze grade')
-  .get(`/${sampleProjectUuid}.json`)
-  .intercept(nock =>
-    nock('https://insight.symfony.com/api/projects')
-      .get(`/${sampleProjectUuid}`)
-      .reply(200, bronzeMockResponse)
-  )
-  .expectBadge({
-    label: 'grade',
-    message: 'bronze',
-    color: '#c88f6a',
-  })
-
-createTest(t, 'no medal grade')
-  .get(`/${sampleProjectUuid}.json`)
-  .intercept(nock =>
-    nock('https://insight.symfony.com/api/projects')
-      .get(`/${sampleProjectUuid}`)
-      .reply(200, noMedalMockResponse)
-  )
-  .expectBadge({
-    label: 'grade',
-    message: 'no medal',
-    color: 'red',
-  })
-
-createTest(t, 'auth missing', { withMockCreds: false })
-  .before(setSymfonyInsightCredsToFalsy)
-  .get(`/${sampleProjectUuid}.json`)
-  .expectBadge({
-    label: 'symfony insight',
-    message: 'required API tokens not found in config',
-  })
-  .after(restore)
diff --git a/services/symfony/symfony-insight-stars.tester.js b/services/symfony/symfony-insight-stars.tester.js
index d6eac18b9208d7a100570d8c1c962547c967ef21..8d7710598193b975656e79889b9b56f9a7f2ec07 100644
--- a/services/symfony/symfony-insight-stars.tester.js
+++ b/services/symfony/symfony-insight-stars.tester.js
@@ -2,28 +2,12 @@
 
 const t = (module.exports = require('../tester').createServiceTester())
 const { withRegex } = require('../test-validators')
-const {
-  createTest,
-  runningMockResponse,
-  platinumMockResponse,
-  goldMockResponse,
-  silverMockResponse,
-  bronzeMockResponse,
-  noMedalMockResponse,
-  prepLiveTest,
-  sampleProjectUuid,
-  realTokenExists,
-} = require('./symfony-test-helpers')
+const { sampleProjectUuid, checkShouldSkip } = require('./symfony-test-helpers')
 
-createTest(t, 'live: valid project stars', { withMockCreds: false })
-  .before(prepLiveTest)
+t.create('valid project stars')
+  .skipWhen(checkShouldSkip)
   .get(`/${sampleProjectUuid}.json`)
   .timeout(15000)
-  .interceptIf(!realTokenExists, nock =>
-    nock('https://insight.symfony.com/api/projects')
-      .get(`/${sampleProjectUuid}`)
-      .reply(200, platinumMockResponse)
-  )
   .expectBadge({
     label: 'stars',
     message: withRegex(
@@ -31,93 +15,10 @@ createTest(t, 'live: valid project stars', { withMockCreds: false })
     ),
   })
 
-createTest(t, 'live (stars): nonexistent project', { withMockCreds: false })
-  .before(prepLiveTest)
+t.create('stars: nonexistent project')
+  .skipWhen(checkShouldSkip)
   .get('/abc.json')
-  .interceptIf(!realTokenExists, nock =>
-    nock('https://insight.symfony.com/api/projects')
-      .get('/abc')
-      .reply(404)
-  )
   .expectBadge({
     label: 'symfony insight',
     message: 'project not found',
   })
-
-createTest(t, 'pending project stars')
-  .get(`/${sampleProjectUuid}.json`)
-  .intercept(nock =>
-    nock('https://insight.symfony.com/api/projects')
-      .get(`/${sampleProjectUuid}`)
-      .reply(200, runningMockResponse)
-  )
-  .expectBadge({
-    label: 'stars',
-    message: 'pending',
-    color: 'lightgrey',
-  })
-
-createTest(t, 'platinum stars')
-  .get(`/${sampleProjectUuid}.json`)
-  .intercept(nock =>
-    nock('https://insight.symfony.com/api/projects')
-      .get(`/${sampleProjectUuid}`)
-      .reply(200, platinumMockResponse)
-  )
-  .expectBadge({
-    label: 'stars',
-    message: '★★★★',
-    color: '#e5e4e2',
-  })
-
-createTest(t, 'gold stars')
-  .get(`/${sampleProjectUuid}.json`)
-  .intercept(nock =>
-    nock('https://insight.symfony.com/api/projects')
-      .get(`/${sampleProjectUuid}`)
-      .reply(200, goldMockResponse)
-  )
-  .expectBadge({
-    label: 'stars',
-    message: '★★★☆',
-    color: '#ebc760',
-  })
-
-createTest(t, 'silver stars')
-  .get(`/${sampleProjectUuid}.json`)
-  .intercept(nock =>
-    nock('https://insight.symfony.com/api/projects')
-      .get(`/${sampleProjectUuid}`)
-      .reply(200, silverMockResponse)
-  )
-  .expectBadge({
-    label: 'stars',
-    message: '★★☆☆',
-    color: '#c0c0c0',
-  })
-
-createTest(t, 'bronze stars')
-  .get(`/${sampleProjectUuid}.json`)
-  .intercept(nock =>
-    nock('https://insight.symfony.com/api/projects')
-      .get(`/${sampleProjectUuid}`)
-      .reply(200, bronzeMockResponse)
-  )
-  .expectBadge({
-    label: 'stars',
-    message: '★☆☆☆',
-    color: '#c88f6a',
-  })
-
-createTest(t, 'no medal stars')
-  .get(`/${sampleProjectUuid}.json`)
-  .intercept(nock =>
-    nock('https://insight.symfony.com/api/projects')
-      .get(`/${sampleProjectUuid}`)
-      .reply(200, noMedalMockResponse)
-  )
-  .expectBadge({
-    label: 'stars',
-    message: '☆☆☆☆',
-    color: 'red',
-  })
diff --git a/services/symfony/symfony-insight-violations.tester.js b/services/symfony/symfony-insight-violations.tester.js
index 145cdd513a905c72fa01bacc22b6f034072aae88..38cdbdb0b2e9fb6c53f00b260c377d190b816e26 100644
--- a/services/symfony/symfony-insight-violations.tester.js
+++ b/services/symfony/symfony-insight-violations.tester.js
@@ -2,137 +2,15 @@
 
 const t = (module.exports = require('../tester').createServiceTester())
 const { withRegex } = require('../test-validators')
-const {
-  createTest,
-  goldMockResponse,
-  runningMockResponse,
-  prepLiveTest,
-  sampleProjectUuid,
-  realTokenExists,
-  mockSymfonyUser,
-  mockSymfonyToken,
-  criticalViolation,
-  majorViolation,
-  minorViolation,
-  infoViolation,
-  multipleViolations,
-} = require('./symfony-test-helpers')
+const { sampleProjectUuid, checkShouldSkip } = require('./symfony-test-helpers')
 
-createTest(t, 'live: valid project violations', { withMockCreds: false })
-  .before(prepLiveTest)
+t.create('valid project violations')
+  .skipWhen(checkShouldSkip)
   .get(`/${sampleProjectUuid}.json`)
   .timeout(15000)
-  .interceptIf(!realTokenExists, nock =>
-    nock('https://insight.symfony.com/api/projects')
-      .get(`/${sampleProjectUuid}`)
-      .reply(200, multipleViolations)
-  )
   .expectBadge({
     label: 'violations',
     message: withRegex(
       /\d* critical|\d* critical, \d* major|\d* critical, \d* major, \d* minor|\d* critical, \d* major, \d* minor, \d* info|\d* critical, \d* minor|\d* critical, \d* info|\d* major|\d* major, \d* minor|\d* major, \d* minor, \d* info|\d* major, \d* info|\d* minor|\d* minor, \d* info/
     ),
   })
-
-createTest(t, 'pending project grade')
-  .get(`/${sampleProjectUuid}.json`)
-  .intercept(nock =>
-    nock('https://insight.symfony.com/api/projects')
-      .get(`/${sampleProjectUuid}`)
-      .reply(200, runningMockResponse)
-  )
-  .expectBadge({
-    label: 'violations',
-    message: 'pending',
-    color: 'lightgrey',
-  })
-
-createTest(t, 'zero violations')
-  .get(`/${sampleProjectUuid}.json`)
-  .intercept(nock =>
-    nock('https://insight.symfony.com/api/projects')
-      .get(`/${sampleProjectUuid}`)
-      .reply(200, goldMockResponse)
-  )
-  .expectBadge({
-    label: 'violations',
-    message: '0',
-    color: 'brightgreen',
-  })
-
-createTest(t, 'critical violations')
-  .get(`/${sampleProjectUuid}.json`)
-  .intercept(nock =>
-    nock('https://insight.symfony.com/api/projects')
-      .get(`/${sampleProjectUuid}`)
-      .reply(200, criticalViolation)
-  )
-  .expectBadge({
-    label: 'violations',
-    message: '1 critical',
-    color: 'red',
-  })
-
-createTest(t, 'major violations')
-  .get(`/${sampleProjectUuid}.json`)
-  .intercept(nock =>
-    nock('https://insight.symfony.com/api/projects')
-      .get(`/${sampleProjectUuid}`)
-      .reply(200, majorViolation)
-  )
-  .expectBadge({
-    label: 'violations',
-    message: '1 major',
-    color: 'orange',
-  })
-
-createTest(t, 'minor violations')
-  .get(`/${sampleProjectUuid}.json`)
-  .intercept(nock =>
-    nock('https://insight.symfony.com/api/projects')
-      .get(`/${sampleProjectUuid}`)
-      .basicAuth({
-        user: mockSymfonyUser,
-        pass: mockSymfonyToken,
-      })
-      .reply(200, minorViolation)
-  )
-  .expectBadge({
-    label: 'violations',
-    message: '1 minor',
-    color: 'yellow',
-  })
-
-createTest(t, 'info violations')
-  .get(`/${sampleProjectUuid}.json`)
-  .intercept(nock =>
-    nock('https://insight.symfony.com/api/projects')
-      .get(`/${sampleProjectUuid}`)
-      .basicAuth({
-        user: mockSymfonyUser,
-        pass: mockSymfonyToken,
-      })
-      .reply(200, infoViolation)
-  )
-  .expectBadge({
-    label: 'violations',
-    message: '1 info',
-    color: 'yellowgreen',
-  })
-
-createTest(t, 'multiple violations grade')
-  .get(`/${sampleProjectUuid}.json`)
-  .intercept(nock =>
-    nock('https://insight.symfony.com/api/projects')
-      .get(`/${sampleProjectUuid}`)
-      .basicAuth({
-        user: mockSymfonyUser,
-        pass: mockSymfonyToken,
-      })
-      .reply(200, multipleViolations)
-  )
-  .expectBadge({
-    label: 'violations',
-    message: '1 critical, 1 info',
-    color: 'red',
-  })
diff --git a/services/symfony/symfony-insight.spec.js b/services/symfony/symfony-insight.spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..ccd61b5643f8cdc4479c9c69e242d213524ca959
--- /dev/null
+++ b/services/symfony/symfony-insight.spec.js
@@ -0,0 +1,255 @@
+'use strict'
+
+const { expect } = require('chai')
+const nock = require('nock')
+const { cleanUpNockAfterEach, defaultContext } = require('../test-helpers')
+const SymfonyInsightGrade = require('./symfony-insight-grade.service')
+const SymfonyInsightStars = require('./symfony-insight-stars.service')
+const SymfonyInsightViolations = require('./symfony-insight-violations.service')
+const {
+  sampleProjectUuid: projectUuid,
+  runningMockResponse,
+  platinumMockResponse,
+  goldMockResponse,
+  silverMockResponse,
+  bronzeMockResponse,
+  noMedalMockResponse,
+  criticalViolation,
+  majorViolation,
+  minorViolation,
+  infoViolation,
+  multipleViolations,
+  user,
+  token,
+  config,
+} = require('./symfony-test-helpers')
+
+// These tests are organized in a fairly unusual way because the service uses
+// XML, so it's difficult to decouple the parsing from the transform + render.
+// It also requires authentication so the tests must be written using a .spec
+// instead of a .tester.
+//
+// In most other cases, do not follow this pattern. Instead, write a .spec file
+// with sazerac tests of the transform and render functions.
+describe('SymfonyInsight[Grade|Stars|Violation]', function() {
+  cleanUpNockAfterEach()
+
+  function createMock() {
+    return nock('https://insight.symfony.com/api/projects')
+      .get(`/${projectUuid}`)
+      .basicAuth({ user, pass: token })
+  }
+
+  it('401 not authorized grade', async function() {
+    const scope = createMock().reply(401)
+    expect(
+      await SymfonyInsightGrade.invoke(defaultContext, config, { projectUuid })
+    ).to.deep.equal({
+      message: 'not authorized to access project',
+      color: 'lightgray',
+      isError: true,
+    })
+    scope.done()
+  })
+
+  function testBadges({
+    description,
+    response,
+    expectedGradeBadge,
+    expectedStarsBadge,
+    expectedViolationsBadge,
+    ...rest
+  }) {
+    if (Object.keys(rest).length > 0) {
+      throw Error(`Oops, what are those doing there: ${rest.join(', ')}`)
+    }
+
+    describe(description, function() {
+      if (expectedGradeBadge) {
+        it('grade', async function() {
+          const scope = createMock().reply(200, response)
+          expect(
+            await SymfonyInsightGrade.invoke(defaultContext, config, {
+              projectUuid,
+            })
+          ).to.deep.equal(expectedGradeBadge)
+          scope.done()
+        })
+      }
+
+      if (expectedStarsBadge) {
+        it('stars', async function() {
+          const scope = createMock().reply(200, response)
+          expect(
+            await SymfonyInsightStars.invoke(defaultContext, config, {
+              projectUuid,
+            })
+          ).to.deep.equal(expectedStarsBadge)
+          scope.done()
+        })
+      }
+
+      if (expectedViolationsBadge) {
+        it('violations', async function() {
+          const scope = createMock().reply(200, response)
+          expect(
+            await SymfonyInsightViolations.invoke(defaultContext, config, {
+              projectUuid,
+            })
+          ).to.deep.equal(expectedViolationsBadge)
+          scope.done()
+        })
+      }
+    })
+  }
+
+  testBadges({
+    description: 'pending project',
+    response: runningMockResponse,
+    expectedGradeBadge: {
+      label: 'grade',
+      message: 'pending',
+      color: 'lightgrey',
+    },
+    expectedStarsBadge: {
+      label: 'stars',
+      message: 'pending',
+      color: 'lightgrey',
+    },
+    expectedViolationsBadge: {
+      label: 'violations',
+      message: 'pending',
+      color: 'lightgrey',
+    },
+  })
+
+  testBadges({
+    description: 'platinum',
+    response: platinumMockResponse,
+    expectedGradeBadge: {
+      label: 'grade',
+      message: 'platinum',
+      color: '#e5e4e2',
+    },
+    expectedStarsBadge: {
+      label: 'stars',
+      message: '★★★★',
+      color: '#e5e4e2',
+    },
+  })
+
+  testBadges({
+    description: 'gold',
+    response: goldMockResponse,
+    expectedGradeBadge: {
+      label: 'grade',
+      message: 'gold',
+      color: '#ebc760',
+    },
+    expectedStarsBadge: {
+      label: 'stars',
+      message: '★★★☆',
+      color: '#ebc760',
+    },
+    expectedViolationsBadge: {
+      label: 'violations',
+      message: '0',
+      color: 'brightgreen',
+    },
+  })
+
+  testBadges({
+    description: 'silver',
+    response: silverMockResponse,
+    expectedGradeBadge: {
+      label: 'grade',
+      message: 'silver',
+      color: '#c0c0c0',
+    },
+    expectedStarsBadge: {
+      label: 'stars',
+      message: '★★☆☆',
+      color: '#c0c0c0',
+    },
+  })
+
+  testBadges({
+    description: 'bronze',
+    response: bronzeMockResponse,
+    expectedGradeBadge: {
+      label: 'grade',
+      message: 'bronze',
+      color: '#c88f6a',
+    },
+    expectedStarsBadge: {
+      label: 'stars',
+      message: '★☆☆☆',
+      color: '#c88f6a',
+    },
+  })
+
+  testBadges({
+    description: 'no medal',
+    response: noMedalMockResponse,
+    expectedGradeBadge: {
+      label: 'grade',
+      message: 'no medal',
+      color: 'red',
+    },
+    expectedStarsBadge: {
+      label: 'stars',
+      message: '☆☆☆☆',
+      color: 'red',
+    },
+  })
+
+  testBadges({
+    description: 'critical violations',
+    response: criticalViolation,
+    expectedViolationsBadge: {
+      label: 'violations',
+      message: '1 critical',
+      color: 'red',
+    },
+  })
+
+  testBadges({
+    description: 'major violations',
+    response: majorViolation,
+    expectedViolationsBadge: {
+      label: 'violations',
+      message: '1 major',
+      color: 'orange',
+    },
+  })
+
+  testBadges({
+    description: 'minor violations',
+    response: minorViolation,
+    expectedViolationsBadge: {
+      label: 'violations',
+      message: '1 minor',
+      color: 'yellow',
+    },
+  })
+
+  testBadges({
+    description: 'info violations',
+    response: infoViolation,
+    expectedViolationsBadge: {
+      label: 'violations',
+      message: '1 info',
+      color: 'yellowgreen',
+    },
+  })
+
+  testBadges({
+    description: 'multiple violations',
+    response: multipleViolations,
+    expectedViolationsBadge: {
+      label: 'violations',
+      message: '1 critical, 1 info',
+      color: 'red',
+    },
+  })
+})
diff --git a/services/symfony/symfony-test-helpers.js b/services/symfony/symfony-test-helpers.js
index 57a215b85b389c21064e550bebc088166636d4df..9030c304b0e995326470860c4e25c0b8a5d00d98 100644
--- a/services/symfony/symfony-test-helpers.js
+++ b/services/symfony/symfony-test-helpers.js
@@ -1,6 +1,5 @@
 'use strict'
 
-const sinon = require('sinon')
 const serverSecrets = require('../../lib/server-secrets')
 
 const sampleProjectUuid = '45afb680-d4e6-4e66-93ea-bcfa79eb8a87'
@@ -78,53 +77,24 @@ const multipleViolations = createMockResponse({
   ],
 })
 
-const mockSymfonyUser = 'admin'
-const mockSymfonyToken = 'password'
-const originalUuid = serverSecrets.sl_insight_userUuid
-const originalApiToken = serverSecrets.sl_insight_apiToken
-
-function setSymfonyInsightCredsToFalsy() {
-  serverSecrets['sl_insight_userUuid'] = undefined
-  serverSecrets['sl_insight_apiToken'] = undefined
-}
-
-function mockSymfonyInsightCreds() {
-  // ensure that the fields exists  before attempting to stub
-  setSymfonyInsightCredsToFalsy()
-  sinon.stub(serverSecrets, 'sl_insight_userUuid').value(mockSymfonyUser)
-  sinon.stub(serverSecrets, 'sl_insight_apiToken').value(mockSymfonyToken)
-}
-
-function restore() {
-  sinon.restore()
-  serverSecrets['sl_insight_userUuid'] = originalUuid
-  serverSecrets['sl_insight_apiToken'] = originalApiToken
+const user = 'admin'
+const token = 'password'
+const config = {
+  private: {
+    sl_insight_userUuid: user,
+    sl_insight_apiToken: token,
+  },
 }
 
-function prepLiveTest() {
-  // Since the service implementation will throw an error if the creds
-  // are missing, we need to ensure that creds are available for each test.
-  // In the case of the live tests we want to use the "real" creds if they
-  // exist otherwise we need to use the same stubbed creds as all the mocked tests.
-  if (!originalUuid) {
+function checkShouldSkip() {
+  const noToken =
+    !serverSecrets.sl_insight_userUuid || !serverSecrets.sl_insight_apiToken
+  if (noToken) {
     console.warn(
-      'No token provided, this test will mock Symfony Insight API responses.'
+      'No Symfony credentials configured. Service tests will be skipped. Add credentials in local.yml to run these tests.'
     )
-    mockSymfonyInsightCreds()
-  }
-}
-
-function createTest(
-  t,
-  title,
-  { withMockCreds = true } = { withMockCreds: true }
-) {
-  const result = t.create(title)
-  if (withMockCreds) {
-    result.before(mockSymfonyInsightCreds)
-    result.finally(restore)
   }
-  return result
+  return noToken
 }
 
 module.exports = {
@@ -135,17 +105,13 @@ module.exports = {
   silverMockResponse,
   bronzeMockResponse,
   noMedalMockResponse,
-  mockSymfonyUser,
-  mockSymfonyToken,
-  mockSymfonyInsightCreds,
-  setSymfonyInsightCredsToFalsy,
-  restore,
-  realTokenExists: originalUuid,
-  prepLiveTest,
   criticalViolation,
   majorViolation,
   minorViolation,
   infoViolation,
   multipleViolations,
-  createTest,
+  user,
+  token,
+  config,
+  checkShouldSkip,
 }
diff --git a/services/teamcity/teamcity-base.js b/services/teamcity/teamcity-base.js
index 9e3d5d8423ffa5f0d951ae866aa2260f3513b4f9..6a9cdcebda672a48017dff75b310d327880e4ec1 100644
--- a/services/teamcity/teamcity-base.js
+++ b/services/teamcity/teamcity-base.js
@@ -1,9 +1,12 @@
 'use strict'
 
-const serverSecrets = require('../../lib/server-secrets')
 const { BaseJsonService } = require('..')
 
 module.exports = class TeamCityBase extends BaseJsonService {
+  static get auth() {
+    return { userKey: 'teamcity_user', passKey: 'teamcity_pass' }
+  }
+
   async fetch({
     protocol,
     hostAndPath,
@@ -17,28 +20,20 @@ module.exports = class TeamCityBase extends BaseJsonService {
       protocol = 'https'
       hostAndPath = 'teamcity.jetbrains.com'
     }
-    const url = `${protocol}://${hostAndPath}/${apiPath}`
-    const options = { qs }
     // JetBrains API Auth Docs: https://confluence.jetbrains.com/display/TCD18/REST+API#RESTAPI-RESTAuthentication
-    if (serverSecrets.teamcity_user) {
-      options.auth = {
-        user: serverSecrets.teamcity_user,
-        pass: serverSecrets.teamcity_pass,
-      }
+    const options = { qs }
+    const auth = this.authHelper.basicAuth
+    if (auth) {
+      options.auth = auth
     } else {
       qs.guest = 1
     }
 
-    const defaultErrorMessages = {
-      404: 'build not found',
-    }
-    const errors = { ...defaultErrorMessages, ...errorMessages }
-
     return this._requestJson({
-      url,
+      url: `${protocol}://${hostAndPath}/${apiPath}`,
       schema,
       options,
-      errorMessages: errors,
+      errorMessages: { 404: 'build not found', ...errorMessages },
     })
   }
 }
diff --git a/services/teamcity/teamcity-build.spec.js b/services/teamcity/teamcity-build.spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..6823b8e899b2c39171fab19bbfbb922a4ac2781f
--- /dev/null
+++ b/services/teamcity/teamcity-build.spec.js
@@ -0,0 +1,38 @@
+'use strict'
+
+const { expect } = require('chai')
+const nock = require('nock')
+const { cleanUpNockAfterEach, defaultContext } = require('../test-helpers')
+const TeamCityBuild = require('./teamcity-build.service')
+const { user, pass, config } = require('./teamcity-test-helpers')
+
+describe('TeamCityBuild', function() {
+  cleanUpNockAfterEach()
+
+  it('sends the auth information as configured', async function() {
+    const scope = nock('https://mycompany.teamcity.com')
+      .get(`/app/rest/builds/${encodeURIComponent('buildType:(id:bt678)')}`)
+      // This ensures that the expected credentials are actually being sent with the HTTP request.
+      // Without this the request wouldn't match and the test would fail.
+      .basicAuth({ user, pass })
+      .reply(200, {
+        status: 'FAILURE',
+        statusText:
+          'Tests failed: 1 (1 new), passed: 50246, ignored: 1, muted: 12',
+      })
+
+    expect(
+      await TeamCityBuild.invoke(defaultContext, config, {
+        protocol: 'https',
+        hostAndPath: 'mycompany.teamcity.com',
+        verbosity: 'e',
+        buildId: 'bt678',
+      })
+    ).to.deep.equal({
+      message: 'tests failed: 1 (1 new), passed: 50246, ignored: 1, muted: 12',
+      color: 'red',
+    })
+
+    scope.done()
+  })
+})
diff --git a/services/teamcity/teamcity-build.tester.js b/services/teamcity/teamcity-build.tester.js
index 21e47fb7e063ad902148e9cd7b7cb5a7b042753e..737bee09e7c3f2fb9f617d22e57e2111012ef257 100644
--- a/services/teamcity/teamcity-build.tester.js
+++ b/services/teamcity/teamcity-build.tester.js
@@ -3,35 +3,29 @@
 const Joi = require('@hapi/joi')
 const { withRegex } = require('../test-validators')
 const t = (module.exports = require('../tester').createServiceTester())
-const {
-  mockTeamCityCreds,
-  pass,
-  user,
-  restore,
-} = require('./teamcity-test-helpers')
 
 const buildStatusValues = Joi.equal('passing', 'failure', 'error').required()
 const buildStatusTextRegex = /^success|failure|error|tests( failed: \d+( \(\d+ new\))?)?(,)?( passed: \d+)?(,)?( ignored: \d+)?(,)?( muted: \d+)?/
 
-t.create('live: codebetter unknown build')
+t.create('codebetter unknown build')
   .get('/codebetter/btabc.json')
   .expectBadge({ label: 'build', message: 'build not found' })
 
-t.create('live: codebetter known build')
+t.create('codebetter known build')
   .get('/codebetter/IntelliJIdeaCe_JavaDecompilerEngineTests.json')
   .expectBadge({
     label: 'build',
     message: buildStatusValues,
   })
 
-t.create('live: simple status for known build')
+t.create('simple status for known build')
   .get('/https/teamcity.jetbrains.com/s/bt345.json')
   .expectBadge({
     label: 'build',
     message: buildStatusValues,
   })
 
-t.create('live: full status for known build')
+t.create('full status for known build')
   .get('/https/teamcity.jetbrains.com/e/bt345.json')
   .expectBadge({
     label: 'build',
@@ -139,29 +133,3 @@ t.create('full build status with failed build')
     message: 'tests failed: 10 (2 new), passed: 99',
     color: 'red',
   })
-
-t.create('with auth')
-  .before(mockTeamCityCreds)
-  .get('/https/selfhosted.teamcity.com/e/bt678.json')
-  .intercept(nock =>
-    nock('https://selfhosted.teamcity.com/app/rest/builds')
-      .get(`/${encodeURIComponent('buildType:(id:bt678)')}`)
-      .query({})
-      // This ensures that the expected credentials from serverSecrets are actually being sent with the HTTP request.
-      // Without this the request wouldn't match and the test would fail.
-      .basicAuth({
-        user,
-        pass,
-      })
-      .reply(200, {
-        status: 'FAILURE',
-        statusText:
-          'Tests failed: 1 (1 new), passed: 50246, ignored: 1, muted: 12',
-      })
-  )
-  .finally(restore)
-  .expectBadge({
-    label: 'build',
-    message: 'tests failed: 1 (1 new), passed: 50246, ignored: 1, muted: 12',
-    color: 'red',
-  })
diff --git a/services/teamcity/teamcity-coverage.spec.js b/services/teamcity/teamcity-coverage.spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..a624187ce044549dd53f8a0b7e11fc3dbd656d2c
--- /dev/null
+++ b/services/teamcity/teamcity-coverage.spec.js
@@ -0,0 +1,43 @@
+'use strict'
+
+const { expect } = require('chai')
+const nock = require('nock')
+const { cleanUpNockAfterEach, defaultContext } = require('../test-helpers')
+const TeamCityCoverage = require('./teamcity-coverage.service')
+const { user, pass, config } = require('./teamcity-test-helpers')
+
+describe('TeamCityCoverage', function() {
+  cleanUpNockAfterEach()
+
+  it('sends the auth information as configured', async function() {
+    const scope = nock('https://mycompany.teamcity.com')
+      .get(
+        `/app/rest/builds/${encodeURIComponent(
+          'buildType:(id:bt678)'
+        )}/statistics`
+      )
+      .query({})
+      // This ensures that the expected credentials are actually being sent with the HTTP request.
+      // Without this the request wouldn't match and the test would fail.
+      .basicAuth({ user, pass })
+      .reply(200, {
+        property: [
+          { name: 'CodeCoverageAbsSCovered', value: '82' },
+          { name: 'CodeCoverageAbsSTotal', value: '100' },
+        ],
+      })
+
+    expect(
+      await TeamCityCoverage.invoke(defaultContext, config, {
+        protocol: 'https',
+        hostAndPath: 'mycompany.teamcity.com',
+        buildId: 'bt678',
+      })
+    ).to.deep.equal({
+      message: '82%',
+      color: 'yellowgreen',
+    })
+
+    scope.done()
+  })
+})
diff --git a/services/teamcity/teamcity-coverage.tester.js b/services/teamcity/teamcity-coverage.tester.js
index 2f70039f1f9b59124bb2f0578a9ff950d5259ad2..b7412db2ce6e48b23fbc6c5d3d02215d5be92a08 100644
--- a/services/teamcity/teamcity-coverage.tester.js
+++ b/services/teamcity/teamcity-coverage.tester.js
@@ -2,32 +2,26 @@
 
 const { isIntegerPercentage } = require('../test-validators')
 const t = (module.exports = require('../tester').createServiceTester())
-const {
-  mockTeamCityCreds,
-  pass,
-  user,
-  restore,
-} = require('./teamcity-test-helpers')
 
-t.create('live: valid buildId')
+t.create('valid buildId')
   .get('/ReactJSNet_PullRequests.json')
   .expectBadge({
     label: 'coverage',
     message: isIntegerPercentage,
   })
 
-t.create('live: specified instance valid buildId')
+t.create('specified instance valid buildId')
   .get('/https/teamcity.jetbrains.com/ReactJSNet_PullRequests.json')
   .expectBadge({
     label: 'coverage',
     message: isIntegerPercentage,
   })
 
-t.create('live: invalid buildId')
+t.create('invalid buildId')
   .get('/btABC999.json')
   .expectBadge({ label: 'coverage', message: 'build not found' })
 
-t.create('live: specified instance invalid buildId')
+t.create('specified instance invalid buildId')
   .get('/https/teamcity.jetbrains.com/btABC000.json')
   .expectBadge({ label: 'coverage', message: 'build not found' })
 
@@ -75,36 +69,3 @@ t.create('zero lines covered')
     message: '0%',
     color: 'red',
   })
-
-t.create('with auth, lines covered')
-  .before(mockTeamCityCreds)
-  .get('/https/selfhosted.teamcity.com/bt678.json')
-  .intercept(nock =>
-    nock('https://selfhosted.teamcity.com/app/rest/builds')
-      .get(`/${encodeURIComponent('buildType:(id:bt678)')}/statistics`)
-      .query({})
-      // This ensures that the expected credentials from serverSecrets are actually being sent with the HTTP request.
-      // Without this the request wouldn't match and the test would fail.
-      .basicAuth({
-        user,
-        pass,
-      })
-      .reply(200, {
-        property: [
-          {
-            name: 'CodeCoverageAbsSCovered',
-            value: '82',
-          },
-          {
-            name: 'CodeCoverageAbsSTotal',
-            value: '100',
-          },
-        ],
-      })
-  )
-  .finally(restore)
-  .expectBadge({
-    label: 'coverage',
-    message: '82%',
-    color: 'yellowgreen',
-  })
diff --git a/services/teamcity/teamcity-test-helpers.js b/services/teamcity/teamcity-test-helpers.js
index 1aa7c1a21b94c0b536356e0a24b8dc49f41ff711..47cfe75a1df64e540048f6ee510444a4011cccac 100644
--- a/services/teamcity/teamcity-test-helpers.js
+++ b/services/teamcity/teamcity-test-helpers.js
@@ -1,25 +1,11 @@
 'use strict'
 
-const sinon = require('sinon')
-const serverSecrets = require('../../lib/server-secrets')
-
 const user = 'admin'
 const pass = 'password'
-
-function mockTeamCityCreds() {
-  serverSecrets['teamcity_user'] = undefined
-  serverSecrets['teamcity_pass'] = undefined
-  sinon.stub(serverSecrets, 'teamcity_user').value(user)
-  sinon.stub(serverSecrets, 'teamcity_pass').value(pass)
-}
-
-function restore() {
-  sinon.restore()
-}
+const config = { private: { teamcity_user: user, teamcity_pass: pass } }
 
 module.exports = {
   user,
   pass,
-  mockTeamCityCreds,
-  restore,
+  config,
 }
diff --git a/services/test-helpers.js b/services/test-helpers.js
new file mode 100644
index 0000000000000000000000000000000000000000..656803b2d734637f5c2891c36d1ade544252ae1d
--- /dev/null
+++ b/services/test-helpers.js
@@ -0,0 +1,24 @@
+'use strict'
+
+const nock = require('nock')
+const request = require('request')
+const { promisify } = require('../core/base-service/legacy-request-handler')
+
+function cleanUpNockAfterEach() {
+  afterEach(function() {
+    nock.restore()
+    nock.cleanAll()
+    nock.enableNetConnect()
+    nock.activate()
+  })
+}
+
+const sendAndCacheRequest = promisify(request)
+
+const defaultContext = { sendAndCacheRequest }
+
+module.exports = {
+  cleanUpNockAfterEach,
+  sendAndCacheRequest,
+  defaultContext,
+}
diff --git a/services/wheelmap/wheelmap.service.js b/services/wheelmap/wheelmap.service.js
index 1c16724a6d306c651d894409c31c31593da81ee8..b3702f0a9aba87399c5f59fefcb6f0ca1e1fe631 100644
--- a/services/wheelmap/wheelmap.service.js
+++ b/services/wheelmap/wheelmap.service.js
@@ -1,10 +1,9 @@
 'use strict'
 
 const Joi = require('@hapi/joi')
-const serverSecrets = require('../../lib/server-secrets')
 const { BaseJsonService } = require('..')
 
-const wheelmapSchema = Joi.object({
+const schema = Joi.object({
   node: Joi.object({
     wheelchair: Joi.string().required(),
   }).required(),
@@ -22,6 +21,10 @@ module.exports = class Wheelmap extends BaseJsonService {
     }
   }
 
+  static get auth() {
+    return { passKey: 'wheelmap_token', isRequired: true }
+  }
+
   static get examples() {
     return [
       {
@@ -49,19 +52,10 @@ module.exports = class Wheelmap extends BaseJsonService {
   }
 
   async fetch({ nodeId }) {
-    let options
-    if (serverSecrets.wheelmap_token) {
-      options = {
-        qs: {
-          api_key: `${serverSecrets.wheelmap_token}`,
-        },
-      }
-    }
-
     return this._requestJson({
-      schema: wheelmapSchema,
+      schema,
       url: `https://wheelmap.org/api/nodes/${nodeId}`,
-      options,
+      options: { qs: { api_key: this.authHelper.pass } },
       errorMessages: {
         401: 'invalid token',
         404: 'node not found',
diff --git a/services/wheelmap/wheelmap.spec.js b/services/wheelmap/wheelmap.spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..d406aeceb559ced3f7cb6d137dcaf211375d441d
--- /dev/null
+++ b/services/wheelmap/wheelmap.spec.js
@@ -0,0 +1,61 @@
+'use strict'
+
+const { expect } = require('chai')
+const nock = require('nock')
+const { cleanUpNockAfterEach, defaultContext } = require('../test-helpers')
+const Wheelmap = require('./wheelmap.service')
+
+describe('Wheelmap', function() {
+  cleanUpNockAfterEach()
+
+  const token = 'abc123'
+  const config = { private: { wheelmap_token: token } }
+
+  function createMock({ nodeId, wheelchair }) {
+    const scope = nock('https://wheelmap.org')
+      .get(`/api/nodes/${nodeId}`)
+      .query({ api_key: token })
+
+    if (wheelchair) {
+      return scope.reply(200, { node: { wheelchair } })
+    } else {
+      return scope.reply(404)
+    }
+  }
+
+  it('node with accessibility', async function() {
+    const nodeId = '26699541'
+    const scope = createMock({ nodeId, wheelchair: 'yes' })
+    expect(
+      await Wheelmap.invoke(defaultContext, config, { nodeId })
+    ).to.deep.equal({ message: 'yes', color: 'brightgreen' })
+    scope.done()
+  })
+
+  it('node with limited accessibility', async function() {
+    const nodeId = '2034868974'
+    const scope = createMock({ nodeId, wheelchair: 'limited' })
+    expect(
+      await Wheelmap.invoke(defaultContext, config, { nodeId })
+    ).to.deep.equal({ message: 'limited', color: 'yellow' })
+    scope.done()
+  })
+
+  it('node without accessibility', async function() {
+    const nodeId = '-147495158'
+    const scope = createMock({ nodeId, wheelchair: 'no' })
+    expect(
+      await Wheelmap.invoke(defaultContext, config, { nodeId })
+    ).to.deep.equal({ message: 'no', color: 'red' })
+    scope.done()
+  })
+
+  it('node not found', async function() {
+    const nodeId = '0'
+    const scope = createMock({ nodeId })
+    expect(
+      await Wheelmap.invoke(defaultContext, config, { nodeId })
+    ).to.deep.equal({ message: 'node not found', color: 'red', isError: true })
+    scope.done()
+  })
+})
diff --git a/services/wheelmap/wheelmap.tester.js b/services/wheelmap/wheelmap.tester.js
index 09470a92ff87b37066a26fa0487136639fea69dd..051bb31140ec44226a39ae8bd007a96dbe78ca83 100644
--- a/services/wheelmap/wheelmap.tester.js
+++ b/services/wheelmap/wheelmap.tester.js
@@ -3,31 +3,20 @@
 const serverSecrets = require('../../lib/server-secrets')
 const t = (module.exports = require('../tester').createServiceTester())
 
-const noToken = !serverSecrets.wheelmap_token
-function logTokenWarning() {
+function checkShouldSkip() {
+  const noToken = !serverSecrets.wheelmap_token
   if (noToken) {
     console.warn(
-      "No token provided, this test will mock Wheelmap's API responses."
+      'No Wheelmap token configured. Service tests will be skipped. Add a token in local.yml to run these tests.'
     )
   }
+  return noToken
 }
 
 t.create('node with accessibility')
-  .before(logTokenWarning)
+  .skipWhen(checkShouldSkip)
   .get('/26699541.json')
   .timeout(7500)
-  .interceptIf(noToken, nock =>
-    nock('https://wheelmap.org/')
-      .get('/api/nodes/26699541')
-      .reply(
-        200,
-        JSON.stringify({
-          node: {
-            wheelchair: 'yes',
-          },
-        })
-      )
-  )
   .expectBadge({
     label: 'accessibility',
     message: 'yes',
@@ -35,21 +24,9 @@ t.create('node with accessibility')
   })
 
 t.create('node with limited accessibility')
-  .before(logTokenWarning)
+  .skipWhen(checkShouldSkip)
   .get('/2034868974.json')
   .timeout(7500)
-  .interceptIf(noToken, nock =>
-    nock('https://wheelmap.org/')
-      .get('/api/nodes/2034868974')
-      .reply(
-        200,
-        JSON.stringify({
-          node: {
-            wheelchair: 'limited',
-          },
-        })
-      )
-  )
   .expectBadge({
     label: 'accessibility',
     message: 'limited',
@@ -57,21 +34,9 @@ t.create('node with limited accessibility')
   })
 
 t.create('node without accessibility')
-  .before(logTokenWarning)
+  .skipWhen(checkShouldSkip)
   .get('/-147495158.json')
   .timeout(7500)
-  .interceptIf(noToken, nock =>
-    nock('https://wheelmap.org/')
-      .get('/api/nodes/-147495158')
-      .reply(
-        200,
-        JSON.stringify({
-          node: {
-            wheelchair: 'no',
-          },
-        })
-      )
-  )
   .expectBadge({
     label: 'accessibility',
     message: 'no',
@@ -79,14 +44,9 @@ t.create('node without accessibility')
   })
 
 t.create('node not found')
-  .before(logTokenWarning)
+  .skipWhen(checkShouldSkip)
   .get('/0.json')
   .timeout(7500)
-  .interceptIf(noToken, nock =>
-    nock('https://wheelmap.org/')
-      .get('/api/nodes/0')
-      .reply(404)
-  )
   .expectBadge({
     label: 'accessibility',
     message: 'node not found',