Documentation
Every response RudeAuth sends is signed. Your binary carries a public key that can verify a reply but never forge one, so a patched server, a proxy, or a hosts-file redirect cannot fake a successful licence check.
Quickstart
Four steps from nothing to a working licence check.
Copy-paste this prompt into Claude Code, Cursor, or any LLM and it integrates
RudeAuth the right way. It reads the rules at
/llms.txt and each SDK's
AGENTS.md, so it will not reach for the patchable
bool is_licensed() pattern.
Add RudeAuth licensing to my <language> app.
Read https://docs.rudeauth.com/llms.txt for how RudeAuth works, and follow the
rudeauth-<lang> SDK's AGENTS.md rules: no bool is_licensed(), embed the public key,
verify before trust, no offline cache, and gate real logic into a server-delivered
payload. My app id is <APP_ID> and my public key is <PUBLIC_KEY>. Create an application
In the dashboard, open Applications and create one. It is issued its own Ed25519 signing keypair. Copy the application ID and the public key. Both are embedded in your binary, and neither is a secret.
Generate licence keys
Licences → Generate. Choose how many, how many days they last (0 for perpetual) and how many devices each key may bind to.
Keys are displayed once here, and looked up by a peppered HMAC. A copy sealed under your master key is kept so you can export a batch or re-reveal a single key later; both are written to the audit log. The master key lives in the environment, not the database, so a stolen database is a list of digests and ciphertext on its own.
Integrate an SDK
RudeAuth ships official SDKs for five languages. Each verifies every
response against the embedded public key before trusting a field, and none
exposes a bool is_licensed() for a patch to flip. Pick yours:
#include <rudeauth/rudeauth.hpp>
rudeauth::Client client(APP_ID, PUBLIC_KEY, "https://api.rudeauth.com");
auto auth = client.authenticate(userKey);
if (!auth.ok()) return 1; // no bool to patch
auto session = auth.value();dotnet add package RudeAuth · source
using RudeAuth;
using var client = new RudeAuthClient(appId, publicKey, "https://api.rudeauth.com");
using var session = client.Authenticate(userKey); // throws RudeAuthException on failurepip install rudeauth · source
from rudeauth import Client, RudeAuthError
client = Client(app_id, public_key, "https://api.rudeauth.com")
session = client.authenticate(user_key) # raises RudeAuthError on failurego get github.com/Rudevin17/rudeauth-go
import "github.com/Rudevin17/rudeauth-go"
client, err := rudeauth.NewClient(appID, publicKey, "https://api.rudeauth.com")
if err != nil { return err }
sess, err := client.Authenticate(userKey) // sentinel errors, e.g. rudeauth.ErrLicenseExpired
if err != nil { return err }
defer sess.Close()cargo add rudeauth · source
use rudeauth::Client;
let client = Client::new(app_id, public_key, "https://api.rudeauth.com")?;
let session = client.authenticate(user_key)?; APP_ID and the public key come from your application in the
dashboard. Both are safe to embed: the public key verifies responses and
cannot forge them.
Check your integration
The rules above are only worth anything if something checks them. The CLI does, locally:
rudeauth-cli review ./src
It exits 1 when something matched and 0 when
nothing did, so it works as a CI step with no wrapper around it. It
needs no token, no database and no network, because it has to run on a
build machine that has never heard of RudeAuth.
| Check | Why it matters |
|---|---|
bool-gate | The whole entitlement becomes one branch, and a patched binary only has to invert it |
key-from-config | A public key read at runtime is a public key an attacker can swap for their own |
offline-fallback | A cache is exactly what an attacker induces by blocking the network |
generic-error | Expired, device-limit, banned and revoked need different answers, and one catch-all tells the user none of them |
Your source never leaves your machine. There is no upload, no telemetry and no key. Asking for your code in exchange for a lint result would be a poor trade for you and a liability for us to hold, so the check runs where the code already is.
It cannot tell you an integration is sound, and it does not
claim to. It matches known patterns rather than parsing your
program, so a clean run means those patterns were not found, not that
nothing is wrong. Two of the rules are not here at all: whether you
gated real logic rather than a splash screen, and whether your own copy
overclaims, both need judgment. Point an assistant at
llms.txt and your SDK's AGENTS.md
for those.
Go and C++ today. The other three SDK languages follow once the rule set has settled, because a check that cries wolf is one people learn to skip.
Verify it refuses
Revoke the licence in the dashboard and run your software again. It should be refused immediately. A licensing system you have only watched succeed is one you have not tested.
Revoke the test keyDashboard → Licences → Revoke
Run the client againDo not change the local key or the device
Expect a typed refusalThe response is signed; no session is issued
- Decision
- REFUSED
- Trust result
- Nothing released
- Recovery
- Use a valid licence or restore access
How verification works
A client never trusts the network. Instead of asking "am I licensed?" and believing the answer, it checks a signature it can verify offline.
- Signed responses. Every reply is Ed25519-signed by your application's key. The signature covers the exact response bytes and the endpoint name, so a reply from one endpoint cannot be replayed as another's.
- Device binding. A licence binds to hardware using K-of-N matching, so replacing a disk does not lock your customer out, but copying the key to another machine does not work.
- Replay protection. Each handshake carries a client nonce, remembered server-side for the length of the clock-skew window. Capturing a successful response and replaying it fails.
- Session keys. The handshake performs an X25519 exchange. Variables and files come back sealed to that session, so they are useless if captured.
Verifying a response
Every endpoint returns the same envelope:
- RequestFresh nonce and device proof
- EnvelopeData bytes and signature
- VerifyEd25519 public key
- ParseOnly after acceptance
{
"data": "<base64 of the JSON payload>",
"signature": "<base64 Ed25519 signature>"
}
Verify the signature over the raw decoded bytes of data,
using your application's public key, before parsing anything inside it.
Parsing first and verifying second defeats the purpose.
The signature is domain-separated by endpoint name, so a valid
handshake response cannot be presented as a files
response.
The SDK does this for you. If you are writing your own client, it is the one part you must not skip: an unverified envelope is just JSON from the network.
One exception.
A request that cannot be attributed to an application, a malformed body,
or an unknown app_id, is answered with an unsigned
{"error":"BAD_REQUEST"} and HTTP 400. There is no signing key
to sign it with. Treat any unsigned response as a failure.
Handshake
/v1/handshake Redeems a licence key, binds the device, and opens a session. Called once per launch.
Request
| Field | Type | Notes |
|---|---|---|
app_id | string | UUID from the dashboard |
app_version | string | Your build version, recorded in the audit log |
license_key | string | The customer's key |
fingerprint_components | string[] | Hardware identifiers. Maximum 16, see below |
fingerprint_label | string | Human-readable device name, shown in the dashboard |
client_nonce | string | base64. Fresh random bytes per request |
eph_pubkey | string | base64. 32-byte X25519 public key |
sent_at | int64 | Unix seconds. Must be within the clock-skew window |
Response payload
{
"success": true,
"client_nonce": "<echoed back>",
"server_time": 1788134400,
"session_token": "<base64>",
"session_expires_at": 1788135300,
"session_id": "<uuid>",
"server_eph_pubkey": "<base64>",
"license": {
"level": 1,
"expires_at": 1790726400,
"devices_used": 1,
"max_devices": 3
}
}
Derive the session key with X25519 against server_eph_pubkey,
using session_id as HKDF info. The server never stores your
ephemeral private key.
On failure, success is false and error
holds one of the error codes. The
response is still signed.
Heartbeat
/v1/heartbeat Confirms a session is still valid and extends it. Call periodically if your software runs for a long time. Revoking a licence kills its sessions, and the heartbeat is how a running client learns that.
| Field | Type |
|---|---|
app_id | string |
session_token | string (base64) |
{ "success": true, "valid": true, "server_time": 1788134400, "expires_at": 1788135300 } valid: false means the session was revoked or expired. Stop
the protected functionality, do not retry until the next handshake.
Variables
/v1/variables Fetches server-side values, sealed to the session.
| Field | Type |
|---|---|
app_id | string |
session_token | string (base64) |
Returns sealed: base64 ciphertext, opened with the session key.
Know the limit. A variable reaches the client, so a determined user can read it out of memory. Variables are worth using for values that rotate, an endpoint that moves, a key that changes weekly, not for a secret that must never be seen.
Files
/v1/files Delivers an encrypted payload that never shipped inside your binary. The strongest gate available: a customer without a valid licence never receives the bytes at all.
| Field | Type |
|---|---|
app_id | string |
session_token | string (base64) |
name | string, as uploaded |
Returns sealed and version. An unknown name
returns FILE_NOT_FOUND, which is distinct from a licence
failure. Check for it explicitly rather than reporting "invalid licence"
for a typo in a filename.
Device reset
/v1/device/reset Lets a customer unbind their own devices, subject to the cooldown and lifetime cap you set. This is the single most common support request any licensing system receives, a customer replaced a motherboard, and exposing it in your software saves you answering it.
| Field | Type |
|---|---|
app_id | string |
license_key | string |
Returns RESET_UNAVAILABLE when the cooldown has not elapsed or
the lifetime cap is spent. An operator can always reset from the dashboard,
which bypasses both.
Webhook
/v1/webhook Invokes an endpoint you configured as client callable, through RudeAuth. Your client never learns the destination URL, and the outbound request is SSRF-guarded and never follows redirects.
| Field | Type |
|---|---|
app_id | string |
session_token | string (base64) |
name | string, the endpoint's name |
params | object, string to string |
Endpoints not marked client-callable cannot be reached this way; they only receive events RudeAuth sends, listed below.
Events
An endpoint receives an event only if it is subscribed to it. Put the event names in the endpoint's Events field in the dashboard, comma separated. An endpoint with an empty Events field receives nothing and exists only to be called through the proxy above.
| Event | Sent when |
|---|---|
license.redeemed | A handshake succeeded. Sent on every activation, not only the first |
device.bound | A handshake bound a machine that was not on the licence before |
device.reset | A licence's devices were unbound, whether by the customer or by you |
license.banned | You revoked a licence |
license.expired | A licence passed its expiry. Sent once, when it lapses |
license.renewed | A licence was extended. The key, its devices and its sessions are unchanged |
sharing.flagged | A licence was flagged as likely shared, or its risk evidence changed |
Every body has the same envelope. data carries the fields
for that event.
{
"event": "device.bound",
"at": "2026-08-21T09:14:22Z",
"data": {
"license_id": "8f1c...",
"device_id": "2b40...",
"devices_used": 2,
"max_devices": 3
}
} | Field | Appears in |
|---|---|
license_id | every event |
device_id | device.bound |
session_id | license.redeemed |
devices_used, max_devices | license.redeemed, device.bound |
actor | device.reset, license.banned, license.renewed |
expires_at | license.renewed |
level, score | sharing.flagged |
A body carries ids and counts, never a licence key, a session token or a fingerprint. A webhook lands in your logs, and often in a third party's, so there is nothing in one worth stealing from there. To turn an id into detail, read it back through the Management API or the dashboard, both of which are authenticated.
Delivery retries with backoff for up to ten attempts. Respond
2xx to acknowledge; anything else is a retry. Treat events
as at-least-once and make your handler idempotent, since a retry after a
timeout you actually processed is indistinguishable from a first
delivery.
Error codes
Returned in the signed payload's error field. Match on the
code, never the message. Messages are for humans and may change.
| Code | Meaning | What the client should do |
|---|---|---|
LICENSE_INVALID | Unknown, malformed or revoked key | Ask for the key again |
LICENSE_EXPIRED | The subscription has run out | Point at your renewal page |
DEVICE_LIMIT | All device slots are in use | Offer device reset |
DEVICE_BLACKLISTED | This hardware is banned | Stop. Do not retry |
SESSION_EXPIRED | The session lapsed or was revoked | Handshake again |
CLOCK_SKEW | sent_at is too far from server time | Tell the user to fix their clock |
RATE_LIMITED | Too many attempts | Back off, then retry |
APP_DISABLED | The application is paused or unknown | Stop. Contact the vendor |
ENDPOINT_DISABLED | That endpoint is off for this app | Stop calling it |
FILE_NOT_FOUND | No file by that name | Check the name, not a licence problem |
RESET_UNAVAILABLE | Cooldown or lifetime cap | Tell the user when they may retry |
QUOTA_EXCEEDED | The vendor's plan limit is reached | Vendor action, not the customer's |
SERVER_ERROR | Something failed our side | Retry with backoff |
Deliberate ambiguity.
A missing application and a disabled one both return
APP_DISABLED; an unknown key and a malformed one both return
LICENSE_INVALID, and take the same code path so timing does not
distinguish them either. Precise errors here would be a free oracle for
anyone enumerating keys.
Device fingerprints
Send several independent identifiers. RudeAuth hashes each one before it touches the database. Raw values are never stored.
Useful components: CPU ID, motherboard serial, disk serial, MAC address, machine GUID.
- Minimum: set per application. A handshake with fewer is refused.
- Maximum 16. More is rejected. Real fingerprints are a handful of values, and an unbounded list is an unbounded row.
- Matching is K-of-N. Enough components must agree, not all of them, so a replaced disk does not lock a customer out.
Avoid values shared across machines. A virtualised disk serial can be identical on thousands of unrelated PCs. Bans on such a component reach far beyond the machine you meant, which is why the dashboard warns before letting you do it.
Rate limits
Limits apply per application and per source address. Exceeding one returns
RATE_LIMITED.
A normal client handshakes once per launch and heartbeats occasionally, so it will not come close. If you are hitting the limit, something is retrying in a loop. Fix the retry, do not raise the ceiling.
Back off exponentially. Retrying immediately makes it worse for every one of your customers.
Something unclear or wrong here? It is a bug in the documentation, and worth reporting as one.