Skip to content

Token

local Token = require("@game/ServerScriptService/Twill/Token")
Token.Configure({ Secret = secretYouKeep })
local code = Token.Issue("promo", { Grant = "starter_pack" }, 7 * 86400)
local payload, reason = Token.Read("promo", code)

Server only.

The point is not the signature. It is the lookup that no longer has to happen.

A token proves its own contents, so a redeem code is handed out in bulk and checked without touching a DataStore, and a payload that travels through somewhere untrusted comes back provably unchanged.

The audience is signed along with the payload and checked on the way back, so a token minted for one purpose is refused by every other.

Pass the same string on both sides and pick a fresh one per purpose: "promo", "invite", "transfer".

The secret is the whole of the security.

  • Keep it server side. It belongs in ServerScriptService, never in ReplicatedStorage.
  • Never send it anywhere.
  • Make it with Random.Id(64).
  • Changing it invalidates every token already issued.

Signing proves origin, not novelty. A valid token stays valid until it expires, so it can be redeemed twice.

Anything that must happen once still needs a record of having happened. Keep the redeemed codes in Data and check the record, the way Monetization does for receipts.

[Server]

Installs the secret every token is signed with.

function Token.Configure(config: Config)
export type Config = {
Secret: string,
}

Call once, during the first boot phase. Throws on a second call, because changing the secret invalidates every token already issued, and throws when the secret is not a string of at least 32 characters.

[Server]

Reports whether a secret has been installed yet.

function Token.IsConfigured(): boolean

Returns

boolean - true once a secret is installed.

For callers that would rather skip a feature than fail on the first token they try to issue.

[Server]

Mints a token carrying a payload that nobody without the secret could have signed.

function Token.Issue(audience: string, payload: any, lifetime: number?): string

Parameters

NameTypeDescription
audiencestringWhat the token is for. The reader names the same one. Not empty.
payloadanyAnything storable, carried inside the token itself.
lifetimenumber?Seconds the token stays good for. Never expires when left out.

Returns

string - The token, safe to send as text.

Throws when used before Configure, on an empty audience, or on a lifetime that is not a number.

The payload is signed, not hidden. Anyone holding the token reads what is in it. Sign what may be seen, and keep secrets out of it.

The payload travels through Compress, so Roblox values survive without a separate encoding step, but they survive lossily: colours quantise, CFrame components drop to f32, and functions and cyclic branches become nil. Carry an identifier rather than a value where exactness matters.

An expiry is recorded as os.time() plus the lifetime, so it is whole seconds of Unix time and is read back against the reading server’s own clock.

[Server]

Reads a token, answering with a reason whenever it should not be honoured.

function Token.Read(audience: string, token: string): (any, Reason?)
export type Reason = "malformed" | "forged" | "expired" | "wrong audience"

Parameters

NameTypeDescription
audiencestringThe purpose the caller expects it to have been minted for.
tokenstringThe text that was issued. Anything else reads as malformed.

Returns

any - The payload, meaningful only when no reason came back with it.

Reason? - Why to refuse it, or nil when it is good.

Throws when used before Configure.

Check reason, never the payload. It is nil only when the token was genuine, unexpired, and meant for this audience.

ReasonMeaning
malformedNot a token at all, or its claims are not a table.
forgedThe tag does not match. Someone edited it.
expiredGenuine, but past its lifetime.
wrong audienceGenuine, but minted for something else.

The tag is checked before the payload is decoded, so a forged token is never parsed.

A token is the encoded body, a full stop, and the tag: HMAC-SHA256 over the body, truncated to 32 hexadecimal characters. Tags are compared in constant time, so a comparison cannot be timed to recover the correct one character at a time.

LimitValue
Shortest secret32 characters
Tag32 hexadecimal characters
Expiry resolution1 second