Skip to content
AF
Cookbook

Recipe: Encrypt OAuth Tokens at Rest, With Key Rotation

Last verified November 5, 2026 · 8 min read

A dependency-free Node module that encrypts OAuth access and refresh tokens before they reach your database, using AES-256-GCM from node:crypto with a fresh random IV per seal. Each ciphertext is bound to the row it belongs to through GCM's additional authenticated data, so a token copied onto another user's row, or swapped between the access and refresh columns, fails authentication instead of decrypting. Every sealed value names its key id, so rotation is add a key, make it active, and rewrap rows as they are read; opening with a deleted key fails with a reason rather than returning garbage.

Covered here:Polar

Every fitness integration ends up holding a table of long-lived keys to people's health data. The refresh-rotation recipe keeps that table correct; this one keeps it unreadable to anyone who gets the table without the key. It is AES-256-GCM from node:crypto, used the way Node's documentation describes, with the two pieces people usually leave out: a binding from each ciphertext to its row, and a key id on every value so the key can change.

Why the lifetimes make this worth doing#

The site's guides quote Withings refresh tokens lasting one year, and Polar documentation that, in one section, says access tokens "will not expire unless explicitly revoked by partners or users". A dump of plaintext tokens is a dump of every connected user's data for that long.

What Node's documentation requires#

As read on November 5, 2026: cipher.setAAD() "must be called before cipher.update()"; cipher.getAuthTag() "should only be called after encryption has been completed using the cipher.final() method"; on decrypt, "if no tag is provided, or if the cipher text has been tampered with, decipher.final() will throw, indicating that the cipher text should be discarded due to failed authentication"; and from Node 26, "using GCM tag lengths other than 128 bits without specifying the authTagLength option when creating decipher is not allowed". The recipe always uses a 16-byte tag and always passes authTagLength.

What the recipe does#

  • Seals under the active key with a fresh 12-byte random IV, and writes tv1.<keyId>.<iv>.<tag>.<ciphertext>, all base64url.
  • Binds to the row. A required context goes in as additional authenticated data and is never stored; sealGrant adds the field name, so access and refresh tokens cannot be swapped.
  • Fails closed with a reason: malformed, unknown-key or auth-failed, never a partial plaintext.
  • Rotates. needsRewrap and rewrap move a row to the active key in one step.

The implementation#

The canonical file is cookbook/token-vault.mjs in this site's repository, and cookbook/token-vault.test.mjs runs against it on every CI run. The listing below is a verbatim copy of that file. It has no runtime dependencies and needs Node 20 or newer; keys are passed in, so they come from your secret manager rather than from code.

/**
 * token-vault.mjs — encrypt OAuth grants at rest with AES-256-GCM, bind each
 * ciphertext to the row it belongs to, and rotate the key without a
 * migration window.
 *
 * WHY
 *   A fitness grant is long-lived: the site's guides quote Withings refresh
 *   tokens lasting a year and Polar access tokens that, in one section of
 *   Polar's documentation, "will not expire unless explicitly revoked". A
 *   database dump of plaintext tokens is a dump of every user's health data
 *   for that long. This file keeps the tokens sealed and the key elsewhere.
 *
 * WHAT THIS IMPLEMENTS
 *   `createTokenVault({ keys, activeKeyId })` returns
 *     seal(plaintext, context)   → "tv1.<keyId>.<iv>.<tag>.<ciphertext>"
 *                                  (base64url parts), sealed under the active
 *                                  key with a fresh random 12-byte IV
 *     open(sealed, context)      → plaintext, or throws TokenVaultError
 *     needsRewrap(sealed)        → true when sealed under a non-active key
 *     rewrap(sealed, context)    → the same plaintext under the active key
 *     sealGrant / openGrant      → the same for a whole grant object
 *   `context` (for example user id + vendor + field) is fed to GCM as
 *   additional authenticated data. It is not stored; it must be supplied
 *   again to open, so a ciphertext copied onto another user's row fails
 *   authentication instead of decrypting into the wrong account.
 *
 *   Node's documentation, as read 2026-11-05: setAAD() "must be called
 *   before cipher.update()"; getAuthTag() "should only be called after
 *   encryption has been completed using the cipher.final() method"; and on
 *   decrypt, "if the cipher text has been tampered with, decipher.final()
 *   will throw". The tag is always 16 bytes and passed as authTagLength,
 *   because Node 26 refuses other GCM tag lengths without it.
 *
 *   Rotation: add the new key to `keys`, make it active, and call rewrap on
 *   each row as it is read (or in a sweep). Old keys stay in `keys` until
 *   needsRewrap is false everywhere; then delete them.
 *
 * PAGES THIS SUPPORTS
 *   https://aifitnessapi.com/cookbook/refresh-rotation (what goes in the vault)
 *   https://aifitnessapi.com/integrate/withings-api
 *   https://aifitnessapi.com/integrate/polar-api
 *
 * Keys come from your secret store, never from this repository. No
 * dependencies. Node 20+.
 *
 * MIT — from aifitnessapi.com/cookbook
 */
import { createCipheriv, createDecipheriv, randomBytes as nodeRandomBytes } from "node:crypto";

const ALGORITHM = "aes-256-gcm";
const VERSION = "tv1";
const IV_BYTES = 12;
const TAG_BYTES = 16;
const KEY_BYTES = 32;
const KEY_ID = /^[A-Za-z0-9_-]{1,32}$/;

export class TokenVaultError extends Error {
  constructor(reason, message) {
    super(message ?? reason);
    this.name = "TokenVaultError";
    /** "malformed" | "unknown-key" | "auth-failed" */
    this.reason = reason;
  }
}

const b64u = (buf) => Buffer.from(buf).toString("base64url");
const unb64u = (s) => Buffer.from(s, "base64url");

function contextBytes(context) {
  if (context == null) throw new TypeError("a context is required: pass what identifies the row (e.g. user id, vendor, field)");
  return Buffer.from(typeof context === "string" ? context : JSON.stringify(context), "utf8");
}

/**
 * @param {{ keys: Record<string, Buffer|Uint8Array>, activeKeyId: string, randomBytes?: (n: number) => Buffer }} opts
 */
export function createTokenVault({ keys, activeKeyId, randomBytes = nodeRandomBytes }) {
  const table = new Map();
  for (const [id, key] of Object.entries(keys ?? {})) {
    if (!KEY_ID.test(id)) throw new TypeError(`key id "${id}" must be 1-32 characters of A-Z a-z 0-9 _ -`);
    if (!key || key.length !== KEY_BYTES) throw new TypeError(`key "${id}" must be ${KEY_BYTES} bytes for ${ALGORITHM}`);
    table.set(id, Buffer.from(key));
  }
  if (!table.has(activeKeyId)) throw new TypeError(`activeKeyId "${activeKeyId}" is not in keys`);

  function seal(plaintext, context) {
    if (typeof plaintext !== "string") throw new TypeError("plaintext must be a string");
    const iv = randomBytes(IV_BYTES);
    const cipher = createCipheriv(ALGORITHM, table.get(activeKeyId), iv, { authTagLength: TAG_BYTES });
    cipher.setAAD(contextBytes(context));
    const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
    const tag = cipher.getAuthTag();
    return [VERSION, activeKeyId, b64u(iv), b64u(tag), b64u(ct)].join(".");
  }

  function parse(sealed) {
    const parts = typeof sealed === "string" ? sealed.split(".") : [];
    if (parts.length !== 5 || parts[0] !== VERSION) throw new TokenVaultError("malformed", "not a tv1 sealed value");
    const [, keyId, iv, tag, ct] = parts;
    const ivBuf = unb64u(iv);
    const tagBuf = unb64u(tag);
    if (ivBuf.length !== IV_BYTES || tagBuf.length !== TAG_BYTES) throw new TokenVaultError("malformed", "bad IV or tag length");
    return { keyId, iv: ivBuf, tag: tagBuf, ct: unb64u(ct) };
  }

  function open(sealed, context) {
    const { keyId, iv, tag, ct } = parse(sealed);
    const key = table.get(keyId);
    if (!key) throw new TokenVaultError("unknown-key", `no key "${keyId}": was it deleted before every row was rewrapped?`);
    const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: TAG_BYTES });
    decipher.setAAD(contextBytes(context));
    decipher.setAuthTag(tag);
    try {
      return Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf8");
    } catch {
      throw new TokenVaultError("auth-failed", "authentication failed: wrong key, wrong context, or tampered ciphertext");
    }
  }

  function needsRewrap(sealed) {
    return parse(sealed).keyId !== activeKeyId;
  }

  function rewrap(sealed, context) {
    return needsRewrap(sealed) ? seal(open(sealed, context), context) : sealed;
  }

  /** Seal every token field of a grant; non-secret fields stay readable. */
  function sealGrant(grant, context, secretFields = ["access_token", "refresh_token"]) {
    const out = { ...grant };
    for (const f of secretFields) if (typeof grant[f] === "string") out[f] = seal(grant[f], { ...ctxObject(context), field: f });
    return out;
  }

  function openGrant(row, context, secretFields = ["access_token", "refresh_token"]) {
    const out = { ...row };
    for (const f of secretFields) if (typeof row[f] === "string") out[f] = open(row[f], { ...ctxObject(context), field: f });
    return out;
  }

  return { seal, open, needsRewrap, rewrap, sealGrant, openGrant, activeKeyId };
}

function ctxObject(context) {
  return typeof context === "string" ? { context } : { ...context };
}

/** A fresh 32-byte key, for your secret store's setup script. */
export function generateKey(randomBytes = nodeRandomBytes) {
  return randomBytes(KEY_BYTES);
}

How to adapt it#

Keep the key out of the database. Load it from the environment or a secret manager at start-up; generateKey() makes one for the setup script.

Make the context stable. Use immutable identifiers (your internal user id, the vendor slug), never something a user can edit. The context is serialized with JSON.stringify, so build it with the same key order every time.

Rewrap on read. After a rotation, call rewrap wherever you open a grant and write back the result when it changed; a background sweep finishes the long tail before you delete the old key.

The contract the tests hold it to#

A sealed token opens with the same key and context and never contains the plaintext; two seals of one token share neither IV nor ciphertext; a different user id or field fails authentication; a flipped ciphertext byte and a swapped tag fail; the wrong key under the right id fails; a truncated tag, a plaintext value and null are malformed. A row sealed under k1 opens during rotation, rewraps to k2, rewrapping again is a no-op, and once k1 is gone the old row fails as unknown-key. sealGrant leaves non-secret fields readable and catches swapped token columns. Bad keys, a missing active key and a missing context are refused.

What it deliberately does not do#

It does not manage keys, which belong in a key management service or secret store; it does not hide token metadata such as expiry or scope, which your refresh scheduler needs to read; and it is not a substitute for revoking tokens when a user disconnects.

Frequently asked questions

Why encrypt fitness API refresh tokens if the database is already encrypted at rest?

Because disk encryption protects against a stolen disk, not against a database dump, a backup copied to the wrong bucket, a read replica with looser access or a SQL injection, all of which see plaintext columns. A fitness refresh token is a standing key to someone's health data: the site's Withings guide quotes refresh tokens lasting a year, and Polar's documentation says in one section that access tokens will not expire unless revoked. Sealing the token columns with a key held in a separate secret store means a leak of the database alone leaks ciphertext, and the key can be rotated without touching the data's owner.

What does binding a sealed token to its database row protect against?

Moving ciphertext around. Without a binding, anyone who can write to the token table can copy their own valid ciphertext onto another user's row, or a migration bug can shift tokens between users, and decryption succeeds either way. The recipe feeds a context, such as user id, vendor and field name, to AES-GCM as additional authenticated data. The context is not stored with the ciphertext; it has to be supplied again to open it, so a token opened under a different user id or field fails authentication. Node's documentation says that if the cipher text has been tampered with, decipher.final() will throw.

How do you rotate the key that encrypts stored OAuth tokens without downtime?

Make every sealed value carry the id of the key that sealed it, then rotate in three steps: add the new key alongside the old one and make it the active key, so new seals use it while old rows still open; rewrap each old row under the new key as it is read, or in a background sweep; and delete the old key only when no row still names it. The recipe's needsRewrap and rewrap do the middle step, rewrap never writes plaintext, and opening a row whose key was deleted throws an unknown-key error, which is the signal that the sweep was not finished.

All 63 questions in Cookbook →

Keep reading

Elsewhere on the site

Pages that share this one’s concepts and sources, from other sections.

Next steps

Was this page useful?

Corrections that change a page are logged, and credited by name if you ask: contributors.

Cite this page

Title
Recipe: Encrypt OAuth Tokens at Rest, With Key Rotation
Publisher
AIFitnessAPI
URL
https://aifitnessapi.com/cookbook/token-vault
Last verified
Plain text
AIFitnessAPI. "Recipe: Encrypt OAuth Tokens at Rest, With Key Rotation." Last verified 2026-11-05. https://aifitnessapi.com/cookbook/token-vault
BibTeX
@misc{aifitnessapi-cookbook-token-vault,
  author = {{AIFitnessAPI}},
  title = {Recipe: Encrypt OAuth Tokens at Rest, With Key Rotation},
  year = {2026},
  url = {https://aifitnessapi.com/cookbook/token-vault},
  urldate = {2026-11-05},
  note = {Last verified 2026-11-05}
}

Cite the page, not the site — every page carries its own verification date.

Independent comparison, last reviewed November 5, 2026. Pricing, rate limits, and feature availability change often — confirm current details in each provider’s official documentation before you commit. Product and company names are trademarks of their respective owners; AIFitnessAPI is not affiliated with, endorsed by, or sponsored by any product listed here.

← All cookbook · by AIFitnessAPI