---
title: "Recipe: Encrypt OAuth Tokens at Rest, With Key Rotation"
canonical: "https://aifitnessapi.com/cookbook/token-vault"
cluster: "Cookbook"
primary_query: "encrypt oauth refresh tokens at rest node aes-256-gcm"
last_reviewed: "2026-11-05"
description: "Tested Node code that seals fitness API tokens with AES-256-GCM, binds each one to its row so a copied token fails, and rotates keys without downtime."
publisher: "AIFitnessAPI — independent, not sponsored"
cite_as: "\"Recipe: Encrypt OAuth Tokens at Rest, With Key Rotation\", AIFitnessAPI, https://aifitnessapi.com/cookbook/token-vault"
---

# Recipe: Encrypt OAuth Tokens at Rest, With Key Rotation

> 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.

- Canonical: https://aifitnessapi.com/cookbook/token-vault
- Last reviewed: 2026-11-05
- Publisher: AIFitnessAPI (https://aifitnessapi.com) — independent, not sponsored
- Cite as: "Recipe: Encrypt OAuth Tokens at Rest, With Key Rotation", AIFitnessAPI, https://aifitnessapi.com/cookbook/token-vault

---

Every fitness integration ends up holding a table of long-lived keys to people's health data. The [refresh-rotation recipe](/cookbook/refresh-rotation) 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](/integrate/withings-api) refresh tokens lasting one year, and [Polar](/integrate/polar-api) 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.

```js
/**
 * 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.

## FAQ

### 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.

[Permalink](https://aifitnessapi.com/cookbook/token-vault#faq-1)

### 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.

[Permalink](https://aifitnessapi.com/cookbook/token-vault#faq-2)

### 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.

[Permalink](https://aifitnessapi.com/cookbook/token-vault#faq-3)
