Oban. Pro. Keyring behaviour
(Oban Pro v1.8.0-rc.1)
A behaviour for supplying versioned encryption keys to encrypted workers.
Encrypted workers configured with a static :key have no way to rotate
that key. Every job is encrypted and decrypted with whatever the key option resolves to at the
moment it runs, so swapping the key leaves any job inserted beforehand unreadable.
A keyring fixes that by naming keys. Each job records the identifier of the key used to encrypt it, and that identifier travels with the job, so a job encrypted last month still decrypts after the current key moves on. Old and new keys coexist for as long as jobs reference them, which also means mixed-version clusters rotate without coordination.
Defining a Keyring
A keyring provides which key to use for new jobs, and how to find a key by identifier. The source of those keys is entirely up to you, it could be runtime configuration, a secrets manager, or a KMS-backed cache.
defmodule MyApp.Keyring do
@behaviour Oban.Pro.Keyring
@impl Oban.Pro.Keyring
def current_key do
{"2026-07", Application.fetch_env!(:my_app, :current_job_key)}
end
@impl Oban.Pro.Keyring
def fetch_key(key_id) do
:my_app
|> Application.fetch_env!(:job_keys)
|> Map.fetch(key_id)
end
endKeys are 32 byte, Base 64 encoded binaries, exactly like the standard encrypted :key option.
Generate a key like this:
key = 32 |> :crypto.strong_rand_bytes() |> Base.encode64()Identifiers are opaque strings stored in job meta, so they're visible in the database and in
Oban Web. Use something meaningful for operators, like a date or a sequence number, and never
derive them from the key material itself.
Cache Key Lookups
Both callbacks run once per job, on insert and again on execution. Inserting ten thousand jobs
calls current_key/0 ten thousand times. Be sure to resolve keys from a cache rather than
calling out to a secrets manager inline.
Using a Keyring
Point an encrypted worker at the keyring module instead of a key:
use Oban.Pro.Worker, encrypted: [keyring: MyApp.Keyring]To share one keyring across every encrypted worker, set it as a compile time default and have workers opt in with an empty option list:
# config.exs
config :oban_pro, Oban.Pro.Worker, encrypted: [keyring: MyApp.Keyring]
# Anywhere sensitive args are used
use Oban.Pro.Worker, encrypted: []Defaults Apply to Every Worker
A top level encrypted default applies to all workers, not only those that name the option.
Workers that shouldn't encrypt their args need encrypted: false to opt out.
Migrating Existing Jobs
Jobs inserted before the worker used a keyring won't have an identifier recorded. The keyring
will receive nil for those, which is your opportunity to keep the original key available:
@impl Oban.Pro.Keyring
def fetch_key(nil), do: Map.fetch(keys(), "old")
def fetch_key(kid), do: Map.fetch(keys(), kid)Keeping every lookup in fetch_key/1 means one place decides which key applies, and the
legacy clause can be deleted once no unversioned jobs remain.
Rotating Keys
Rotation is deliberately gradual, and old jobs stay readable until they age out:
- Add the new key to
fetch_key/1on every node, leavingcurrent_key/0unchanged - Deploy and confirm both identifiers resolve
- Switch
current_key/0to the new identifier, new jobs use it immediately - Leave the retired key in
fetch_key/1until no jobs reference it - Remove the retired key
There's no fallback if a key goes missing. A job whose identifier can't be resolved fails with an error naming the missing identifier.
Summary
Types
A 32 byte, Base 64 encoded encryption key.
An opaque identifier for a key, stored in job meta.
Types
Callbacks
@callback current_key() :: {key_id(), encoded_key()}
Return the identifier and key used to encrypt new jobs.
Called once for each job on insert. Both values come back together so the identifier recorded in
meta always matches the key the args were encrypted with, even if rotation happens midway
through a batch of inserts.
@callback fetch_key(key_id() | nil) :: {:ok, encoded_key()} | :error
Look up a key by identifier.
Called once for each job on execution. The identifier is nil for jobs encrypted before the
worker used a keyring.
Returning :error fails the job with an error naming the identifier, and the job retries with
normal backoff.