RFC 9700 Opaque rotating refresh tokens - v1.0.0 - Apache-2.0

The refresh token,
finally specified.

Rotation, reuse detection and family revocation - the model major identity providers run internally - as one precise specification with reference implementations in ten languages. Drop it into the backend you already have.

shared test vectorszero runtime depsyour database, your dataquantum-resistant by design
Interactive playground

See the policy at work

Inspect a token like on jwt.io - except there is nothing to decode. Then play attacker and watch reuse detection burn the family.

TOKEN INSPECTORStructural parser — SPECIFICATION.md §2, requirements [N-5] to [N-9]

Parsed entirely in this browser tab. No network request, no analytics event, no storage — the text above never leaves your device. Even so, treat a real refresh token as a password: do not paste a production token into any web page, including this one. The box is pre-filled with a randomly generated fake.

prefix
nbl — reserved by the spec ([N-51])
kid
k1 — pepper key identifier
selector
GT46tR830L85uO600zy4Xw — 128-bit lookup key, safe to index
verifier
it59P7_e2K… (32 bytes, canonical base64url)
Structurally valid. Zero claims. Pure entropy.

Unlike a JWT, this token carries no payload, no algorithm field and no signature to confuse. The selector points at one server-side record; the verifier proves possession of it.

The server never stores this string. It keeps a keyed hash — HMAC-SHA-256 of the verifier under a pepper — and the pepper lives in your environment or KMS, never in the database. The hash is one-way: it is not encryption and there is no key that turns it back into a token. A stolen database dump is inert, because the rows contain no credential and no way to reconstruct one.

Threat model first

The token is not the defense.
The policy is.

A stolen refresh token that keeps working for days is the real threat - and no amount of encryption inside the token fixes it. Server-side policy does.

Rotation & reuse detection

Every refresh invalidates the presented token. Replaying a dead one burns the whole session family - the attacker is cut off and the theft surfaces as a security event you can alert on.

Hashed at rest, keyed by kid

The server stores only HMAC-SHA-256(pepper, verifier). Peppers live in your env or KMS and rotate with zero downtime. A full database dump yields nothing replayable. And with no public-key crypto anywhere, Shor's algorithm has no target: the token layer is quantum-resistant by construction, with a ~128-bit margin even against Grover.

When to use NEBULA — and when not to

Use NEBULA when you build first-party authentication in your own backend and want refresh-token handling that matches what managed identity providers do internally: no per-user pricing, no vendor lock-in, session data in your own database (relevant for GDPR / data residency), identical behavior across every language in your stack.

Do not use NEBULA when: you already run a managed IdP (Auth0, Okta, Cognito — use its built-in rotation); you need full OAuth 2.0 / OIDC with third-party clients, scopes, and consent screens (you need an authorization server such as Keycloak or Ory, not a token library); you operate under FAPI 2.0 / open-banking requirements (you additionally need cryptographic sender-constraining — DPoP or mTLS — at the protocol layer; NEBULA's device binding is application-level); or your stack is a single language and its authorization-server framework already does this (OpenIddict, Spring Authorization Server, Fosite, Authlib — each is better integrated with its ecosystem than NEBULA will ever be, and the ten-language conformance guarantee is worth nothing to you).

FAQ

Why does it need a database? Because instant revocation and reuse detection require server-side state — a stateless token cannot be revoked before it expires, which is exactly why RFC 9700 moved the industry away from stateless refresh tokens. NEBULA doesn't pick your database: it defines a six-method contract you implement over whatever you already run. Cost: one indexed lookup per refresh.

Why not a JWT as refresh token? A JWT proves who signed it, but cannot be un-signed: revocation requires a server-side denylist — at which point you have a database anyway, plus a signature scheme you no longer need. Claims belong in the short-lived access token; the refresh token is a credential, not an envelope.

Why not self-host Ory, Keycloak or Supabase Auth? If you need what those are — an identity provider that owns login, consent and the OAuth 2.0 protocol surface — run one, and take its rotation with it. The question only gets interesting when the refresh-token mechanism is all you need. Those engines are multi-language in the sense that any language can call their HTTP API: there is one implementation, behind a network hop, that you deploy, patch, monitor and keep available, and it wants to own your user table. NEBULA is multi-language in a different sense — ten in-process implementations that agree byte-for-byte, pinned by shared vectors. A refresh is an HMAC and one compare-and-set write against the database you already run: no service in the path, nothing extra that can be down, and session data that stays in your own schema. What NEBULA competes with is not an identity provider; it is the rotation logic you would otherwise write by hand — where the usual defect is the concurrent refresh: two parallel requests presenting the same token both observe it as active, both mint a successor, and the family forks into two independently valid lineages — which defeats reuse detection entirely. That is not an exotic race; it is a mobile client retrying, or two browser tabs refreshing together. Here it is a compare-and-set with a specified losing branch ([N-34] step 5, returning a retryable CONFLICT per [N-35]) rather than something left to the reader.

What is genuinely new here? Not the cryptography — deliberately. What did not exist before is a precise, testable, language-independent specification of the RFC 9700 rotation model: exact check order, exact reuse/grace semantics, shared conformance vectors, and ten implementations held to them by one executable suite rather than by ten hand-written ones.

Maturity, stated plainly

A library on an authentication path should tell you what it has and has not been through. NEBULA's position, as of 1.0.0:

  • No independent security audit. None has been performed. SECURITY.md keeps the full list of what is missing — no formal verification of the rotation state machine, no fuzzing beyond the published vectors.
  • No production track record. These implementations are new. Nobody can yet tell you how they behave at scale, because nobody has run them at scale.
  • What is real is the conformance. Behaviour is not asserted in prose and hoped for in code: it is pinned by 46 test vectors and 38 executable behavioural scenarios that every implementation runs on every CI run, in all ten languages, with a runner that must assert the published case counts and fail on an empty section ([N-48]). Two implementations that pass agree at every point those artefacts observe: the accept/reject decision for the token strings the parsing vectors cover, the byte-level output of both keyed hashes, and — per scenario — the outcome code, the generation, the family and expiry relations, and the resulting multiset of record statuses in the store. That is a bounded claim rather than a universal one, and it is deliberately the one this project makes: the vectors do not observe timing, log output or store call ordering, and no differential harness yet drives all ten from one random transcript. It is also checkable — checkable by you, not just by our CI: node scripts/docker-test.mjs runs all ten suites in official images pinned by digest at the declared version floors, with nothing installed but Docker (docker/README.md).
  • The surface is small on purpose. Each implementation is one small, dependency-free module meant to be read in a sitting. The most useful thing you can do before adopting it is read it.

What is frozen and what may still move is spelled out in COMPATIBILITY.md; how a third-party port may claim conformance ([N-53]) is in GOVERNANCE.md.

Implementations

Ten languages, one behavior

Every port passes the shared test vectors and the full behavioral suite. Pick your language, copy the quickstart, keep the exact same semantics.

Select Language

TypeScript / Node

Same engine, same store contract, same error codes as every other port.

Runtime Dependencieszero runtime dependencies
Footprintsingle file, ~12 kB unminified
SpecificationRFC 9700 profile, spec v1
Conformanceshared vectors + behavioral suite
Install
npm install nebula-token
QUICKSTART
import { NebulaEngine, MemoryRefreshTokenStore } from 'nebula-token';

const engine = new NebulaEngine({
  peppers: { k1: process.env.NEBULA_PEPPER_K1! }, // >= 32 chars, env/KMS
  activeKid: 'k1',
  store: new MemoryRefreshTokenStore(), // implement RefreshTokenStore for prod
});

// Login -> start a token family
const { token } = await engine.issue('usr_92', deviceId);

// Refresh -> rotate
const result = await engine.refresh(presented, deviceId);
if (result.ok) sendToken(result.token);
else if (result.error === 'REUSE_DETECTED') alertSecurity();
Agent skill

An agent skill is a short instruction file an AI coding assistant loads before it writes code. NEBULA ships ten of them, one per implementation, because the call sequence and the traps are language-shaped. This one teaches an assistant to integrate NEBULA in TypeScript / Node the way the specification says to — and it carries the five rules that are easiest to get wrong:

  • [N-35] CONFLICT is transient — retry the refresh exactly once.
  • [N-30] reuseGraceSeconds defaults to 0. Raising it buys retry tolerance and costs detectability: inside the window a thief is served a valid token and no REUSE_DETECTED is raised.
  • [N-21] The in-memory store is not for production: per-process, lost on restart, blind behind two instances.
  • [N-39] Failures carry userId and familyId whenever a record resolved — the security event needs no second lookup.
  • [N-46] Never log a token, the verifier, the pepper or the raw device id. The selector may be logged as a correlation id.
Install it — any agent
npx skills add nebula-token/nebula-token --skill nebula-token-typescript

Same command for all ten; only the --skill argument changes. --skill '*' takes the lot, -l lists them without installing, -a picks the agent (claude-code, codex, …). It installs into the current project — .claude/skills/ for Claude Code, committed and shared with your team; -g installs into ~/.claude/skills/ for you alone.

Or natively, in Claude Code
/plugin marketplace add nebula-token/nebula-token
/plugin install nebula-token-typescript@nebula-token

The marketplace is added once. After that every language is one /plugin install away, and /plugin marketplace update re-syncs the catalogue.

No network for npx? Offline and vendored routes
From the release archive
curl -fsSLO https://github.com/nebula-token/nebula-token/releases/latest/download/nebula-token-skills.zip
unzip -j nebula-token-skills.zip 'nebula-token-typescript/*' -d ~/.claude/skills/nebula-token-typescript/

All ten are in that one archive — nebula-token-skills.zip. And if the package is already installed you need neither: every published artefact carries the same nebula-token-typescript/ directory, ready to copy into a skills directory as-is. Where that directory sits inside each ecosystem's artefact — the jar and the Composer package do not put it in the same place as the rest — is in the repository's skills index, which is where that table is maintained.

Anatomy & lifecycle

How it works

The full algorithm is ten precisely ordered checks - this is the shape of it.

nbl . kid . selector . verifier
pepper key id - live rotation128-bit lookup key - safe to index256-bit secret - hashed, never stored
01

Login

issue() mints generation 0 of a new token family. The server stores only an HMAC of the secret - a database dump is inert.

02

Refresh

Every use rotates: the presented token dies, a successor is born. A stolen token is only useful until its owner refreshes.

03

Replay

An already-rotated token presented again means two parties hold one credential. The whole family is revoked instantly - attacker and stale sessions both cut off.

04

Expiry

Two clocks: an absolute deadline fixed at login that nothing extends, and a sliding idle window renewed on each refresh.

05

Key rotation

Peppers are keyed by kid. Rotate them live - old tokens keep verifying, new tokens use the new key, nobody gets logged out.