Agora
Keystore vaults

Keystore vaults

Where the managed JWKS signing keystore is persisted — every jwks.store driver, its fields and defaults, and the three cloud secrets-manager companion packages.

When AuthKit runs with a managed JWKS (jwks: { source: 'managed' }), it generates the RS256/ES256 signing key on first boot, persists the private keystore, and rotates it on demand. Where that private keystore lives is the single most security-sensitive storage decision in the whole system — anyone who can read it can mint tokens your relying parties will trust.

That decision is expressed in one place: jwks.store. It accepts a small tagged union — a filesystem path, one of five built-in drivers, or one of three cloud secrets managers delivered as companion packages. This page documents every variant, then the three cloud packages in detail.

PackageBackendDriver
@adonis-agora/authkit-vault-awsAWS Secrets Manageraws-secrets-manager
@adonis-agora/authkit-vault-azureAzure Key Vaultazure-key-vault
@adonis-agora/authkit-vault-gcpGCP Secret Managergcp-secret-manager

Any of this only matters when the JWKS is managed. If you supply keys inline (jwks: { source: 'jwks', keys: [...] }) or hand AuthKit a pre-existing jwks_uri, there is no private keystore to persist and no store to configure.

Omitting store under source: 'managed' is not "the default file store" — it means no persistence at all. AuthKit generates a fresh key on every boot: tokens issued before a restart stop validating, and node ace authkit:keys:rotate has nothing to rotate. It is fine for a scratch dev process and wrong everywhere else. node ace authkit:doctor reports it.

The jwks.store config surface

store is a union of nine forms: a bare string (a shortcut), five built-in drivers, and three cloud drivers. Every form is a plain object literal with a driver discriminator, so your editor narrows the remaining fields as soon as you type the driver name.

String shortcut — a file path

The shortest form. A string is exactly equivalent to { driver: 'file', path: <string> }:

config/authkit.ts
jwks: {
  source: 'managed',
  algorithm: 'RS256',
  store: 'tmp/authkit_jwks.json',
}

Relative paths resolve against the application root (the same base as app.makePath), so tmp/authkit_jwks.json lands next to your project, not next to node_modules.

file

FieldTypeRequiredDefault
pathstringyes

The explicit spelling of the shortcut. The file is created with mode 0600 and the parent directory is created recursively on first write. Change detection for the reload poller uses the file's modification time.

config/authkit.ts
store: { driver: 'file', path: 'storage/authkit/jwks.json' },

A file store is per node. Two app instances behind a load balancer each generate their own key on first boot and each serves only its own key in the discovery JWKS, so tokens signed by one are rejected by the other. Use a shared driver (lucid, redis, hashicorp-vault, or a cloud vault) or a shared volume for any multi-instance deployment.

drive

FieldTypeRequiredDefault
keystringyes
diskstringnothe default disk from your @adonisjs/drive config

Persists the blob as an object on an @adonisjs/drive disk — S3, GCS, or the local driver. Change detection prefers the object's ETag and falls back to its last-modified timestamp.

config/authkit.ts
store: { driver: 'drive', disk: 's3', key: 'authkit/jwks.json' },

@adonisjs/drive is an optional peer. Unlike the avatar storage, which degrades quietly when Drive is missing, the keystore does not: selecting driver: 'drive' without the package installed throws at boot. A signing key is too important to silently fall back on.

lucid

FieldTypeRequiredDefault
tablestringno'authkit_keystore'
connectionstringnothe default Lucid connection
keystringno'jwks'

A one-row key/value table, shared by every instance pointing at the same database — so multi-instance works with no extra infrastructure. The table is created on the first write if it does not exist, including under a boot race between instances.

config/authkit.ts
store: { driver: 'lucid', connection: 'pg', table: 'authkit_keystore', key: 'jwks' },

The auto-create is deliberate rather than a convenience: the keystore is read while the config resolves, which happens before the provider's schema management runs. The vault therefore cannot assume its own table already exists. Reads against a missing table return "no keystore yet" instead of throwing.

redis

FieldTypeRequiredDefault
connectionstringnothe default Redis connection
keystringno'authkit:jwks'

Also shared across instances, and the cheapest to poll — change detection is a plain GET.

config/authkit.ts
store: { driver: 'redis', connection: 'main', key: 'authkit:jwks' },

Redis must be configured for persistence (RDB or AOF). On a cache-only Redis, an eviction or a FLUSHALL deletes the keystore; AuthKit then generates a brand-new key and every previously issued token stops validating. Selecting this driver logs a warning at boot for exactly this reason.

hashicorp-vault

FieldTypeRequiredDefault
endpointstringyes
pathstringyes
tokenstringnonone (no X-Vault-Token header sent)
mountstringno'secret'
fieldstringno'value'

Talks to a HashiCorp Vault KV v2 engine over its HTTP API — no SDK, no extra dependency. Reads hit {endpoint}/v1/{mount}/data/{path} and change detection reads current_version from {endpoint}/v1/{mount}/metadata/{path}.

config/authkit.ts
store: {
  driver: 'hashicorp-vault',
  endpoint: 'https://vault.acme.internal:8200',
  path: 'authkit/jwks',
  mount: 'secret',
  field: 'value',
  token: env.get('VAULT_TOKEN'),
},

A 404 is read as "not written yet" and produces the initial key. Any other non-OK status throws rather than degrading — a transient Vault outage must not be mistaken for an empty keystore, because that would silently mint a replacement key.

aws-secrets-manager

FieldTypeRequiredDefault
secretIdstringyes
regionstringnothe AWS SDK's own region resolution (env, profile, instance metadata)

Requires @adonis-agora/authkit-vault-aws plus its optional peer @aws-sdk/client-secrets-manager (>=3).

config/authkit.ts
store: {
  driver: 'aws-secrets-manager',
  secretId: 'prod/authkit/jwks',
  region: 'us-east-1',
},

The first write creates the secret if it does not exist; subsequent writes add a version. Change detection uses the secret's VersionId.

gcp-secret-manager

FieldTypeRequiredDefault
namestringyes

name is the secret resource name, projects/{project}/secrets/{secret} — without a version suffix. AuthKit reads {name}/versions/latest and writes by adding a new version to that secret. Requires @adonis-agora/authkit-vault-gcp plus its optional peer @google-cloud/secret-manager (>=5).

config/authkit.ts
store: {
  driver: 'gcp-secret-manager',
  name: 'projects/acme-prod/secrets/authkit-jwks',
},

Credentials come from Application Default Credentials — the same resolution the Google SDK uses everywhere else, so a workload-identity service account needs no extra config here. Change detection uses the resolved version's resource name.

azure-key-vault

FieldTypeRequiredDefault
vaultUrlstringyes
secretNamestringyes

Requires @adonis-agora/authkit-vault-azure plus its optional peers @azure/keyvault-secrets (>=4) and @azure/identity (>=4).

config/authkit.ts
store: {
  driver: 'azure-key-vault',
  vaultUrl: 'https://acme-prod.vault.azure.net',
  secretName: 'authkit-jwks',
},

Authentication goes through DefaultAzureCredential, so managed identity, environment variables, or a developer's az login all work without a code change. Change detection uses the secret version.

How a cloud driver is loaded

The three cloud packages are companions, not dependencies. authkit-server never imports them statically; the driver name in your config is what pulls one in. What you can rely on:

Selecting the driver is enough. You never import a vault package or call its factory yourself. driver: 'aws-secrets-manager' maps to @adonis-agora/authkit-vault-aws, 'gcp-secret-manager' to -vault-gcp, 'azure-key-vault' to -vault-azure. An unrecognised driver name fails fast at boot with the offending value in the message.

The companion package is imported lazily, on first keystore I/O. Declaring the driver costs nothing at module load; the dynamic import happens the first time the keystore is actually read or written, during config resolution at boot. That is what lets authkit-server ship all three drivers in its type union while an app installs at most one of them — and it is why the cloud SDKs are optional peers of the companion packages rather than hard dependencies.

The whole store object is handed to the companion package. Extra fields are ignored; each package reads only the keys it declares (secretId/region, name, or vaultUrl/secretName). driver itself is passed through and ignored.

A selected-but-missing package fails loudly, never silently. If the companion package is not installed, the first keystore access throws an error naming the exact package to install. There is no fallback to a local file and no ephemeral key — a signing key that quietly changed backends would be worse than a failed boot. The same is true one level down: if the companion package is installed but its cloud SDK peer is not, the SDK's own import failure surfaces instead of being swallowed.

Because the import is lazy, a mistake in a credential (a wrong region, a missing IAM permission) surfaces at the first keystore access rather than at import time. In practice that is still boot: resolving the AuthKit config reads the keystore, so any ace command that boots the app — node ace authkit:doctor is the obvious one — exercises the same path a cold production boot would.

The shared contract

All three packages export the same two types, and implement the same tiny interface — the structural shape AuthKit's keystore consumes:

/** What createKeystoreVault returns — the shape authkit-server's keystore consumes. */
export interface KeystoreVaultLike {
  /** Read the persisted keystore blob, or null if it doesn't exist yet. */
  read(): Promise<string | null>
  /** Persist (create-or-update) the keystore blob. */
  write(blob: string): Promise<void>
  /** Cheap change token (version/etag) for the live-reload poller. Optional. */
  head?(): Promise<string | null>
}

Each factory composes a small, injectable SecretBackend seam, so the cloud SDK calls are isolated and unit-testable without any live cloud:

/** get/put/version — the minimal cloud-secret surface each package adapts to. */
export interface SecretBackend {
  get(): Promise<string | null>
  put(blob: string): Promise<void>
  version?(): Promise<string | null>
}

createKeystoreVault(config) returns a KeystoreVaultLike whose read/write delegate to backend.get/backend.put, and whose head() prefers backend.version() (a cheap version id) falling back to a full get(). Passing your own backend in the config bypasses the cloud SDK entirely — handy for tests or a bespoke transport:

import { createKeystoreVault } from '@adonis-agora/authkit-vault-aws'

const vault = createKeystoreVault({
  secretId: 'prod/authkit/jwks',
  backend: {
    async get() {
      /* return the blob from wherever you like */
      return null
    },
    async put(blob) {
      /* persist it */
    },
    async version() {
      return 'v1'
    },
  },
})

Why head() matters. When several nodes run the same IdP, one node rotating the key writes a new blob to the vault. The others poll head() (a cheap version/etag check) and reload the JWKS in place when it changes — no restart, no shared filesystem. That's why each backend implements a version() that returns the secret's version id rather than the whole payload.

Encryption at rest

AuthKit can also encrypt the keystore blob with your APP_KEY before persisting it (jwks.encrypt). The default is backend-aware, and the split is about whether the backend is a secret manager or a dumb blob store:

Storeencrypt defaultWhy
string shortcut, file, drive, lucid, redistrueThese persist whatever you hand them, in the clear. Anyone with disk, bucket, DB, or Redis access would otherwise read the private key.
hashicorp-vault, aws-secrets-manager, gcp-secret-manager, azure-key-vaultfalseThe backend already encrypts at rest and gates access with its own IAM/ACL, and double-encrypting buys nothing while adding an APP_KEY you can lose.

Set it explicitly when you want defense in depth (app-layer encryption and the vault's own):

config/authkit.ts
jwks: {
  source: 'managed',
  encrypt: true, // APP_KEY-encrypt the blob before it reaches the vault
  store: { driver: 'gcp-secret-manager', name: 'projects/acme/secrets/authkit-jwks' },
}

encrypt: true binds the keystore to that APP_KEY. Rotate or lose the key and the persisted blob can no longer be decoded — you are then rotating the signing key from scratch, which invalidates every token in flight. If you turn it on for a cloud vault, treat APP_KEY with the same care as the vault credentials themselves.

Rotation

Rotation is identical regardless of the backend — the store is just where the blob lands. Run the command (or the Key Rotation admin panel) and the new key is written straight to the configured backend:

node ace authkit:keys:rotate            # rotate, keeping 2 keys (grace period)
node ace authkit:keys:rotate --dry-run  # print the plan, write nothing
node ace authkit:keys:rotate --keep=3   # keep 3 keys in the published JWKS
node ace authkit:keys:rotate --retire   # keep only the new key

The new key goes to the front of the keyset and becomes the signing key; the retained older keys stay in the published JWKS so tokens signed before the rotation keep validating until they expire. --retire skips that grace window and invalidates them immediately.

See Signing key rotation for the full flag reference.

Pick your cloud to continue:

On this page