Skip to content

Secrets and tokens

Use this page when the value grants access: a reset link, an invitation token, an API key or a TOTP secret. An id that is merely hard to guess is not a credential. The rules are short: enough random bits, the right strategy, and no trail.

NeedCodeBits
API key, token, reset linkGenerateValue.Bytes(32)256
Hex token for a header or URLGenerateValue.Bytes(32, ValueEncoding.Hex)256
TOTP secret, key materialGenerateValue.Bytes(20, ValueEncoding.Base32)160
URL-safe token, default alphabetGenerateValue.NanoId(32)192
  • Use at least 128 random bits. Bytes(16) is the floor; Bytes(32) is the default to reach for.
  • Use only Bytes or NanoId for secrets. A UUID v4 has 122 bits and is not designed as a token. UUID v7, ULID and TypeID expose their creation time and carry fewer random bits.
  • Encoding changes the representation, not the entropy: 32 bytes are 256 bits as base64url or as hex.
  • A short, typeable code such as an invitation code is not a secret. Treat it as an identifier with a uniqueness check and an expiry.
using Cratis.Concepts;
public record ApiKey(string Value) : ConceptAs<string>(Value), IGeneratable<ApiKey>
{
public static ApiKey New() => new(GenerateValue.Bytes(32));
}

Generation is not secret storage. In an Arc application with Chronicle, the values of a command’s properties are written to the causation chain of every event the command appends. Without a marking, an ApiKey property is recorded there in the clear and stays as long as the events do. [NotAudited] comes from the Cratis.Arc.Chronicle package. Put it on the concept and the value is left out of the causation chain of every command that has a property of that type. It also works on a command type, a single property, or a constructor parameter.

using Cratis.Arc.Chronicle.Commands;
using Cratis.Concepts;
[NotAudited]
public record ApiKey(string Value) : ConceptAs<string>(Value), IGeneratable<ApiKey>
{
public static ApiKey New() => new(GenerateValue.Bytes(32));
}

[NotAudited] has a narrow scope:

CoveredNot covered
The command causation chainThe payload of an event: an event that carries the token stores it in the clear
Log lines, exception messages, responses and anything else that prints the value
Encryption: the value is withheld from the chain, not protected

[PII] is for personal data such as a name or an email address: Chronicle can then manage and erase the value. Do not use it on a secret that is not personal data. The two markings are not interchangeable.

  1. Show it once. Return the plaintext to the caller at creation time and never again.
  2. Store a hash of bearer tokens. For tokens and API keys that the caller presents back to you, keep only a hash, in events and in storage, and compare hashes on use, so a leak does not leak working keys. GenerateValue does not hash for you. A TOTP secret or other key material is different: you need the original value later to compute codes or sign, so a hash is useless. Keep it in protected, recoverable storage, such as a secret store or encryption at rest, never in an event payload.
  3. Never log it. A concept prints its raw value from ToString(), so Console.WriteLine(apiKey) and $"{apiKey}" write the secret.
  4. Expire and rotate. A token with no lifetime is a permanent credential.