Skip to content
CredenShare
Shares

Client-side encryption

Every constant, derivation and byte layout your client needs to produce a share the recipient can actually open.

A content key is generated in your client and never sent to CredenShare. The server stores your ciphertext plus a hash of an access token derived from that key, and neither one lets it decrypt anything. The key reaches the recipient in the URL fragment, which browsers do not transmit to servers.

That is the whole model. What follows is the exact set of parameters that make it interoperable, because two implementations that disagree on a single one of them produce ciphertext the other cannot read.

Do not substitute your own values here. Every constant on this page — algorithm, length, salt, info string, encoding, byte order — is part of the wire format. A share encrypted with a different info string still posts, still returns 201, and can never be opened by the recipient's browser.
This page is the specification, not the only way to do it. The official SDKs for Node, Python, Go and Rust implement everything below. Read on if you are writing a client in another language, verifying one, or want to understand what the SDKs do on your behalf.

What we receive and what we never receive

Your client holdsCredenShare receives
The content key (32 bytes)Never
The passcode, if anyNever
The plaintext field arrayNever
The URL fragmentNever — browsers do not send fragments
data: your ciphertext, stored verbatim
access_token: stored only as a SHA-256 hash
passcode_verifier: a one-way derivation, if you use a passcode
title and description: plaintext metadata

Primitives

PrimitiveParameters
HashSHA-256
KDFHKDF-SHA-256 (RFC 5869), extract-and-expand
AEADAES-256-GCM, 96-bit IV, 128-bit tag
CurveNIST P-256 (secp256r1), ECDH — only for item_key_wrap

Two encodings appear below, and they are not interchangeable:

TermMeaning
base64Standard base64 with padding (RFC 4648 §4). Used for data, which travels in a JSON body.
base64urlURL-safe base64, no padding (RFC 4648 §5): + becomes -, / becomes _, = stripped. Used for anything that travels in a URL.

HKDF-SHA256(ikm, salt, info, len) below takes info as UTF-8 bytes and returns len bytes.

Where a salt is written "" it is a zero-length byte string. Some HKDF wrappers make the salt argument mandatory; pass an empty buffer there rather than a placeholder of your own. Any filler value that is not zero bytes changes the output, and the recipient — who derives from the key alone — has no way to reproduce it.

Domain separation

Every derivation from the same input uses a distinct info string. This is what lets you hand us the access token while keeping the content key: the two outputs are independent, so possession of one says nothing about the other.

infoPurpose
contentContent encryption key, no passcode
content|<passcode>Content encryption key with a passcode
accessAccess token
verifyPasscode verifier
custodyCustody secret to keypair seed
crs-ecdh-p256-scalarSeed to P-256 private scalar
crs-request-submissionECDH shared secret to wrapping key

Do not add, rename or reuse these strings. A collision silently makes two secrets that must stay independent equal to each other, and nothing anywhere looks wrong.

The content key

contentKey = 32 random bytes

From a cryptographically secure source — crypto.getRandomValues, secrets.token_bytes, crypto/rand. It is generated per share, used for nothing else, and never transmitted.

This is the only thing that can decrypt the share. If you lose it between generating it and building the link, the content is unrecoverable — by you, by the recipient, and by CredenShare.

The plaintext: a field array

The value you encrypt is the JSON serialization of an array of field objects:

[
  { "key": "Database password", "value": "s3cr3t", "type": "password" },
  { "key": "Host", "value": "db.internal.example", "type": "text" }
]
MemberRequiredNotes
keyyesThe field's visible label — what the recipient reads.
valueyesThe field's content.
typeyesOne of text, password, date, multiline, markdown, source_code.
selectedProgrammingLanguagenoLanguage hint for source_code.
filenamenoFor source_code and markdown, offers the recipient a download using this name.
The label member is key. It is not label, name or title. A share built with the wrong member name encrypts, posts, decrypts and renders — with every field label blank and nothing anywhere reporting an error. This is the single most common integration mistake on this path.

Members you add that are not listed here ride along encrypted and come back unchanged on decrypt. An unrecognised type is rendered as text rather than rejected.

The data envelope

data is one base64 string containing three concatenated parts. There is no JSON, no header block and no separator: the parts are found by fixed offset.

salt  = 16 random bytes
iv    = 12 random bytes
key   = HKDF-SHA256(contentKey, salt, "content", 32)
body  = AES-256-GCM(key, iv, utf8(JSON.stringify(fields)))

data  = base64(salt || iv || body)
BytesLengthContent
0–1516salt — the HKDF salt, not a password salt
16–2712iv — the AES-GCM nonce
28 onwardplaintext length + 16ciphertext || tag

The IV lives inside the envelope, at offset 16. It is not a separate request field and there is nowhere to put one.

AES-GCM output is ciphertext || tag in a single buffer, which is what WebCrypto returns. If your AEAD hands back the tag separately, concatenate in that order. The tag is 16 bytes, so the shortest structurally valid envelope is 16 + 12 + 16 = 44 bytes; a reader must reject anything shorter before attempting to decrypt.

data uses standard base64 with padding. It travels in a JSON body, never in a URL, so the URL-safe alphabet is wrong here: the recipient decodes data with a standard-alphabet decoder, and a blob containing - or _ fails to decode at all. Keep the two alphabets straight — data standard, everything in a URL url-safe.

There is no version byte in this envelope. The format version lives in the URL fragment instead, and encryption_type on the create names the scheme:

encryption_type = "e2ee-aes256-gcm"

A worked envelope

Using the two fields above, with the salt and IV fixed so the output is reproducible:

contentKey = 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f
salt       = a0a1a2a3a4a5a6a7a8a9aaabacadaeaf
iv         = b0b1b2b3b4b5b6b7b8b9babb
plaintext  = 123 bytes of UTF-8 JSON
body       = 139 bytes (123 ciphertext + 16 tag)
envelope   = 16 + 12 + 139 = 167 bytes  ->  224 base64 characters
oKGio6SlpqeoqaqrrK2ur7CxsrO0tba3uLm6u1YWPEhJhUdlUBdeJpvQ4hb9yDJknecKV3wgwb0v
GbPEUpQ3l3Ft8RQVYAGz20PsWDGw4+IT+eydl4C7nMEkAFT9MLCfAXV0TRR3Au1iKgpG/aTqaOZ9
y4fehNC2KGj6yRX+TR41DQby7+lFCmEu6gbWCIuVjSIfUsdWk0nbrXhs1LBktPzMM2FSx6o=

The string is wrapped here for the page; send it as one line. Decode it and the first 16 bytes are a0a1…aeaf and the next 12 are b0b1…babb, which is a quick way to confirm your offsets.

Fixed values are shown so you can check your implementation byte for byte. In production the salt and the IV must be freshly random per share. Reusing an IV under the same derived key breaks AES-GCM outright.

With a passcode

A passcode changes exactly one thing — the info string:

key = HKDF-SHA256(contentKey, salt, "content|" + passcode, 32)

The passcode is mixed into info, never into the salt. The salt must stay reproducible from the stored envelope alone; the passcode is not stored anywhere.

The consequence is worth stating plainly: with a passcode, the fragment alone cannot decrypt the share. Someone who intercepts the link still needs the passcode, and CredenShare never has either.

The access token

access_token = base64url(HKDF-SHA256(contentKey, "", "access", 32))

43 characters, no padding. The salt is empty on purpose: the recipient's browser must reproduce this value from the fragment alone, before it has fetched any ciphertext and therefore before it has seen any salt.

CredenShare stores only the hex-encoded SHA-256 of the token's trimmed value, and compares presented tokens in constant time. The token itself is never retained.

It is a bearer capability for reading, not a decryption key. Presenting it proves you hold the link; it is what stops someone who guesses a short code from burning a view-once share or stamping its first-view timestamp before being refused. It buys ciphertext and nothing else — the access and content derivations are independent, so the token reveals nothing about the key.

For the example content key above:

access_token = d2DJ6L1GBXjLD-YhpdyVxJQNkLOHIovvBRUbSMcZf0A

The passcode verifier

passcode_verifier = base64url(HKDF-SHA256(utf8(passcode), "", "verify", 32))

Send the verifier, never the passcode. The passcode feeds the content key derivation, so handing us the passcode would hand us a component of the key — which is the one thing this design exists to prevent. The verifier is one-way, so the server can count failed attempts and enforce lockout without gaining any ability to decrypt.

Derive it from the passcode exactly as the user typed it, with the same bytes you fed into the content|<passcode> info string. Normalising in one place and not the other produces a share that rejects its own correct passcode.

passcode "hunter2"  ->  SzNZ-iyCO2S6YZkhDBGLG162TOJ_zgHxThgosEtPcXY

Passcode protection is a plan feature; a create carrying a verifier on a plan without view protection is refused with a 403 and error_code 53.

fragment = "1" || base64url(contentKey)

A single leading version character, then 43 characters of base64url — 44 characters in total. The fragment is bare: there is no k= prefix and no query-string shape.

That is a safety choice. A visible k=… tail reads as an optional appendix and invites truncation by link-mangling clients, and a truncated fragment produces a permanently unreadable share. As one opaque token it survives copy and paste.

The create response returns a short code, so you assemble the link:

https://crs.sh/{short_code}#1{base64url(contentKey)}
https://crs.sh/a1b2c3d4#1AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8
A link built without the fragment is not a degraded link — it is ciphertext nobody can ever read. Not the recipient, not you, not CredenShare. If your integration ever emits https://crs.sh/{short_code} on its own, it is silently producing dead shares that look fine until someone opens one.

This is also why the create response has no url field. A link contains the content key, we have never had the content key, and returning a keyless link would look correct and fail at the moment of use.

Reading a fragment back

A client that opens links as well as creating them must reject a bad fragment rather than guess, and must distinguish the reasons — "your link is incomplete" and "this share expired" look identical on screen and have opposite remedies.

ConditionReason
Empty or absentmissing-key
Leading character is not 1malformed-key
Body is not valid base64urlmalformed-key
Decoded length is not 32malformed-key

A leading # handed over by the browser is stripped before parsing. Note that base64url decoding must restore the stripped = padding first: some decoders reject unpadded input.

item_key_wrap

An API-created share is readable only from its link, because only the link carries the key. item_key_wrap is the optional field that also makes it readable later from your dashboard: it is the content key wrapped to your API key's custody public key, which we can store without being able to open.

You are the only party who can compute it. It requires the content key, which never reaches us, and the custody secret, which is the third part of your credential and must never be transmitted. See Authentication for custody levels and what each one buys.

The custody public key

seed      = HKDF-SHA256(utf8(custodySecret), "", "custody", 32)
wide      = HKDF-SHA256(seed, "", "crs-ecdh-p256-scalar", 48)
scalar    = (bigEndianInt(wide) mod (n - 1)) + 1        // n = the P-256 group order
publicKey = scalar · G, uncompressed: 0x04 || X(32) || Y(32), 65 bytes

scalar is encoded big-endian in exactly 32 bytes. Deriving 48 bytes rather than 32 before the reduction is deliberate: the extra 128 bits make the modular bias negligible. Adding 1 after reducing mod n - 1 yields a scalar in [1, n - 1], excluding zero, which is not a valid private key.

Any machine holding the credential derives the same keypair, so stateless multi-runner automation needs no local key storage. Revoking the key revokes custody in the same motion: the wraps remain and nothing can open them.

WebCrypto has no scalar-multiplication entry point, so this one step needs a P-256 implementation rather than a browser primitive. Every other construction on this page is pure WebCrypto. This is why item_key_wrap is optional, and why the example below omits it.

The wrap

ephemeral    = a fresh random P-256 keypair, per wrap, never reused
sharedSecret = ECDH(ephemeral.private, custodyPublicKey)     // the 32-byte X coordinate
salt         = 16 random bytes
iv           = 12 random bytes
wrappingKey  = HKDF-SHA256(sharedSecret, salt, "crs-request-submission", 32)
body         = AES-256-GCM(wrappingKey, iv, contentKey)

item_key_wrap = base64(0x01 || ephemeral.public(65) || salt(16) || iv(12) || body)
BytesLengthContent
01Wrap format version, currently 0x01
1–6565Ephemeral public key, uncompressed
66–8116salt
82–9312iv
94 onwardpayload + 16ciphertext || tag

The info string is literally crs-request-submission, even here. It is one shared wrapping construction used for request submissions and for every zero-knowledge wrap; there is no share-specific label, and inventing one produces a wrap that unwraps to nothing.

Wrapping a 32-byte content key produces exactly 142 bytes, or 192 base64 characters: 1 + 65 + 16 + 12 + 32 + 16. That arithmetic is a useful field check on a new implementation.

The ephemeral keypair must be freshly generated for every wrap. Reusing one leaks the relationship between the wraps made with it.

A reader must reject a leading byte other than 0x01 rather than guessing, and must reject a blob shorter than 1 + 65 + 16 + 12 + 16 bytes.

A complete example

Dependency-free, WebCrypto only. This produces a valid POST /v1/shares body and the fragment to build the link with.

const enc = new TextEncoder()

function b64(bytes) {
  let s = ''
  for (const b of bytes) s += String.fromCharCode(b)
  return btoa(s)
}

const b64url = (bytes) => b64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')

async function hkdf(ikm, salt, info, lengthBytes) {
  const base = await crypto.subtle.importKey('raw', ikm, 'HKDF', false, ['deriveBits'])
  const bits = await crypto.subtle.deriveBits(
    { name: 'HKDF', hash: 'SHA-256', salt, info: enc.encode(info) },
    base,
    lengthBytes * 8
  )
  return new Uint8Array(bits)
}

async function buildShare(fields, passcode) {
  const contentKey = crypto.getRandomValues(new Uint8Array(32))
  const salt = crypto.getRandomValues(new Uint8Array(16))
  const iv = crypto.getRandomValues(new Uint8Array(12))

  const info = passcode ? `content|${passcode}` : 'content'
  const aesKey = await crypto.subtle.importKey(
    'raw',
    await hkdf(contentKey, salt, info, 32),
    { name: 'AES-GCM' },
    false,
    ['encrypt']
  )
  const body = new Uint8Array(
    await crypto.subtle.encrypt(
      { name: 'AES-GCM', iv },
      aesKey,
      enc.encode(JSON.stringify(fields))
    )
  )

  // salt(16) || iv(12) || ciphertext+tag
  const envelope = new Uint8Array(16 + 12 + body.length)
  envelope.set(salt, 0)
  envelope.set(iv, 16)
  envelope.set(body, 28)

  const payload = {
    encryption_type: 'e2ee-aes256-gcm',
    data: b64(envelope),
    access_token: b64url(await hkdf(contentKey, new Uint8Array(0), 'access', 32))
  }
  if (passcode) {
    payload.passcode_verifier = b64url(
      await hkdf(enc.encode(passcode), new Uint8Array(0), 'verify', 32)
    )
  }

  // The content key never enters `payload`. It leaves only in the fragment.
  return { payload, fragment: '1' + b64url(contentKey) }
}

Then the request, and the link:

const { payload, fragment } = await buildShare([
  { key: 'Database password', value: 's3cr3t', type: 'password' },
  { key: 'Host', value: 'db.internal.example', type: 'text' }
])

// The first TWO parts of the credential only: crs_sk_live_<keyId>.<authSecret>
// The third part, the custody secret, must never be transmitted.
const credential = process.env.CREDENSHARE_API_KEY

const res = await fetch('https://api.credenshare.io/v1/shares', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${credential}`,
    'Idempotency-Key': 'deploy-42',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    title: 'Deploy credentials',
    expired_at: '2026-09-01T00:00:00Z',
    access_counts_left: 1,
    ...payload
  })
})

const { short_code } = await res.json()
const link = `https://crs.sh/${short_code}#${fragment}`

Idempotency-Key is mandatory on a create — see Create a share for why, and for every other field you can send.

link is the only artefact of this whole operation that can ever open the share. Hand it to the recipient, or send an item_key_wrap on the create so your dashboard can rebuild it. An automation that logs the short_code and discards fragment has written something nobody can open.

Check your implementation before you rely on it

Nothing validates the internal structure of data on create. A malformed envelope, a wrong info string and a truncated fragment are all accepted with a 201 and fail only when a recipient opens the share. A green create tells you the request was well formed, not that the crypto was.

Two checks catch almost every mistake:

  1. Round-trip against fixed inputs. Feed your implementation the content key, salt and IV from the worked envelope above and compare the base64 output character for character. A mismatch localises immediately: wrong data with a correct access_token points at the content derivation or the byte layout; both wrong points at your HKDF wiring — the info bytes, the output length, or the base64 alphabet.
  2. Open one in a real browser. Create a share through your client and click the link you assembled. This is the only check that catches a blank-label field array, a fragment your link builder dropped, and a passcode normalised on one side but not the other — none of which produce an error anywhere.

Then verify the failure path on purpose: strip the fragment from a link and confirm the recipient page reports a missing key rather than appearing to work.