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
end

Keys 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:

  1. Add the new key to fetch_key/1 on every node, leaving current_key/0 unchanged
  2. Deploy and confirm both identifiers resolve
  3. Switch current_key/0 to the new identifier, new jobs use it immediately
  4. Leave the retired key in fetch_key/1 until no jobs reference it
  5. 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.

Callbacks

Return the identifier and key used to encrypt new jobs.

Look up a key by identifier.

Types

encoded_key()

(since 1.8.0)
@type encoded_key() :: String.t()

A 32 byte, Base 64 encoded encryption key.

key_id()

(since 1.8.0)
@type key_id() :: String.t()

An opaque identifier for a key, stored in job meta.

Callbacks

current_key()

(since 1.8.0)
@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.

fetch_key(arg1)

(since 1.8.0)
@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.