s3nd

Sync codes

A code somebody has to read off one screen and type into another — in whatever shape your app needs.

A sync code is the whole user experience of moving between devices: it appears on the old phone, and the user types it into the new one. Everything about it is shaped by that.

const store = createBucket({ bucket: 'my-bucket' })

store.codes.create() // "K7QP2M4X"
store.codes.normalize('k7-qp2m4x') // "K7QP2M4X"

store.codes pairs generation with the normalization that reads codes back, so the two can never disagree about the alphabet. Codes are generated with nanoid.

Choosing a shape

The default is eight characters of Crockford base32 — 40 bits, and unguessable in any useful sense. Both halves are configurable, because a code someone reads over the phone should probably not be eight characters:

import { createBucket, syncCodeAlphabets } from 's3nd'

const store = createBucket({
  bucket: 'my-bucket',
  syncCode: { length: 4, alphabet: syncCodeAlphabets.digits },
})

store.codes.create() // "8143"
store.codes.entropyBits // 13.29

Configuring codes covers the alphabets that ship, what each shape is worth against a guessing attacker, how to write your own alphabet, and what happens to codes already issued when you change the shape.

Why Crockford by default

Crockford base32 drops I, L, O and U. The first three are the characters people misread — I for 1, O for 0, L for 1 — and dropping U keeps a random code from spelling something unfortunate.

normalize() then repairs those misreadings on the way back in, but only when the alphabet makes the repair unambiguous: O folds to zero when the alphabet has a zero and no letter O to confuse it with. That property holds for Crockford and for digits, so both get the repair for free. An alphabet that contains both O and 0 gets no folding, because there would be no way to know which one the user meant.

store.codes.normalize('K7QP2M4X') // "K7QP2M4X"
store.codes.normalize('k7qp2m4x') // "K7QP2M4X" — case folded
store.codes.normalize('K7-QP2M 4X') // "K7QP2M4X" — separators dropped
store.codes.normalize('OIL5ABCD') // "0115ABCD" — misreadings repaired

Case is folded only when the alphabet has a single case. A mixed-case alphabet keeps what the user typed, since upper-casing it would break the lookup.

Call normalize() on user input before you look anything up, and let it throw: a code with a character the alphabet cannot contain does not exist, so INVALID_SYNC_CODE is a better answer than a lookup that finds nothing — and it costs no request.

try {
  const code = store.codes.normalize(typed)
  return await store.getSnapshot(code)
} catch (error) {
  if (isS3ndError(error) && error.code === 'INVALID_SYNC_CODE') {
    return Response.json({ error: 'That does not look like a code' }, { status: 400 })
  }

  throw error
}

How guessable is it

Not very, at the default: 40 bits is about 1.1 × 10¹² codes. A shorter shape trades that away deliberately — four digits is ten thousand codes, which an unthrottled attacker walks through in seconds.

What makes any of them safe is that a live code is a needle in that haystack for a short window:

  • Give every transfer code a short expiresIn. An hour is generous; a working transfer takes a minute.
  • Rate-limit the lookup route. This is what actually protects a short code, and the shorter the code, the more of the work it is doing.
  • Pass ifAbsent: true on write, so a generated code that happens to be taken fails loudly instead of overwriting somebody else's snapshot.
await store.putSnapshot(code, state, { expiresIn: 60 * 60, ifAbsent: true })

The numbers behind that trade-off are on Configuring codes.

A code is a bearer token

Anyone holding a live code can read that snapshot. That is inherent: the receiving device has no account, no session and no keys — the code is the authentication. Which means:

  • Do not log codes, and keep them out of URLs you send to analytics.
  • Do not reuse one code for repeated syncing. Generate a new one per transfer.
  • If the data is genuinely sensitive, do not rely on the code at all: encrypt in the browser before it reaches your server. See end-to-end encrypted sync.

For a continuous per-account backup, skip codes entirely and key the snapshot by user id behind your normal session check — see continuous backup.

Codes are keys, not magic

A code is just an object key, so everything else in the package applies to it. It goes through the same validation as any key, and the configured prefix applies:

const store = createBucket({ bucket: 'my-bucket', prefix: 'snapshots' })

await store.putSnapshot('K7QP2M4X', state) // stores snapshots/K7QP2M4X
await store.delete('K7QP2M4X') // burns it, once the transfer is done

Burning a code after a successful restore is a good habit: the transfer is over, and the token should stop working. There is no separate call for it — delete() is the whole of it.

Outside a store

createSyncCodes(), createSyncCode() and normalizeSyncCode() build or use a scheme without a store — see Configuring codes. Prefer store.codes in application code: configuring the shape in one place is what keeps the two sides in agreement.

On this page