English · 简体中文 · Türkçe · العربية
Video tutorials: youtube.com/@pwfauth
1.7.0 adds user variables: values your program keeps on the server for each customer
(SetVariableAsync, GetVariableAsync, GetVariablesAsync, DeleteVariableAsync), editable
in the panel. See CHANGELOG.md
PWF Auth signs its replies with a new key. PWFAuth 1.5.0 and newer pin it; 1.4.0 cannot talk to the current server, so update and rebuild your application
dotnet add package PWFAuth --version 1.7.01.6.0 works on networks that block the name pwfauth.com (Etisalat in the UAE): when a request
gets no reply at all, it goes to the same server as https://pwfauth.net. 1.5.2 sends a request
once more when the connection drops before any reply (Windows error 10054).
See CHANGELOG.md
All responses, including errors and clock-correction responses, must carry a valid RSA-SHA256 signature from the pinned PWFAuth public key. The private signing key stays on the server. Knowing the application secret is not enough to forge this signature. A fresh random nonce binds each response to its request, method, path, HTTP status and exact body bytes (PWF-REPLY-V1). Query strings are not part of the V1 signed path; production HTTPS protects them in transit
The SDK accepts only https://pwfauth.com; every other origin is rejected. Its default transport validates TLS
certificates and refuses redirects. Missing/invalid signatures fail closed before
JSON parsing or envelope decryption; unsigned proxy/CDN errors are security errors.
There is no fallback to the old unsigned protocol. Update the server before clients.
Applications must never unlock functionality after a security or transport error
This addresses server emulation. It does not prevent an attacker who controls the client machine from modifying the application itself. Keep authoritative valuable operations on the server and keep real app secrets out of public repositories
Official client for PWF Auth — license keys, user accounts, hardware-ID binding, encrypted sessions with a server-side kill switch, free trials, remote content, and OTA update checks
dotnet add package PWFAuthTargets netstandard2.0 (works on .NET Framework 4.6.2+, so WinForms/WPF and VB.NET desktop apps are first-class) and net8.0
- No connection is a
PwfHttpException. Offline, DNS, a firewall, a proxy or TLS: the call throwsPwfHttpExceptionwithStatusCode0 and the network error asInnerException. 1.3.0 let the rawHttpRequestExceptionthrough.LogoutAsyncnow returnsnullin that case, as documented, instead of throwing
- Sign up with a license key, and add keys to extend an account.
RegisterAccountWithKeyAsynccreates an account from a key (it gets the key's time and device limit), andRedeemKeyAsyncadds another key's time to an existing account, even after it has expired. See Accounts that run on license keys below - New
PwfErrorCodes:KeyRequired,KeyAlreadyUsed,KeyInUse,KeyRedeemed,AlreadyLifetime,UsernameExists
- A wrong system clock repairs itself. The server refuses a request whose timestamp is
more than five minutes off, and now sends its own time with the refusal. The client shifts
its timestamps by the difference and sends the request once more, so signing in and the
heartbeat work on a PC whose date or time is wrong — no more "check your clock" support
tickets. Opt out with
PwfClientOptions.AutoCorrectClock = false
ResetHardwareIdAsync— customers move their license to a new PC themselves, instantly (see Let users move their license to a new PC below).RequestHardwareResetAsyncis obsolete: the dashboard does not show those requests yet, so nobody could approve one- Plain "success" replies are rejected. The real server encrypts every reply on the
session and content endpoints, so a plain JSON reply claiming success can only come from
a proxy or a fake server. It now raises
PwfSecurityException. Plain failure replies are still returned as failed responses - The kill switch cannot be dodged by changing the clock. The heartbeat now counts only
encrypted replies as answers, so the plain refusals the server sends to a clock that is
more than five minutes off end the session with
CLOCK_SKEWinstead of being ignored. Plain HTTP 429 gets its own budget,MaxRateLimitedBeats(see Why the heartbeat matters) SessionEndedarrives on the UI thread whenStartHeartbeat()is called from it (WinForms/WPF), so handlers can touch controls directly. Opt out withPwfClientOptions.RaiseEventsOnCapturedContext = false- New
PwfErrorCodes:DeviceLimit,OpenAccessLimit,KeyNotActive,NoHwid,RateLimited,SelfResetDisabled, and the client-sideClockSkew - Every request identifies itself with
User-Agent: PWFAuth-dotnet/1.1.0 (+https://pwfauth.com) - An
HttpClientyou pass in is no longer modified — set itsTimeoutyourself
Full history: CHANGELOG.md
using PWFAuth;
var client = new PwfClient("your-64-char-app-secret");
// The moment an admin bans, pauses, expires, resets or revokes the key — or the
// server becomes unreachable — this fires. Sign the user out here.
client.SessionEnded += (sender, e) =>
{
Console.WriteLine($"Session ended: {e.ErrorCode} — {e.Message}");
Environment.Exit(0);
};
var login = await client.LoginAsync("XXXXX-XXXXX-XXXXX-XXXXX");
if (!login.Success)
{
Console.WriteLine(login.Message); // safe to show the user
return;
}
client.StartHeartbeat(); // keeps the session alive AND enforces the kill switchVB.NET:
Dim client As New PwfClient("your-64-char-app-secret")
AddHandler client.SessionEnded, Sub(s, e)
MessageBox.Show(e.Message)
Application.Exit()
End Sub
Dim login = Await client.LoginAsync("XXXXX-XXXXX-XXXXX-XXXXX")
If login.Success Then client.StartHeartbeat()In a form, that StartHeartbeat() runs on the UI thread, so SessionEnded is raised on
the UI thread too and the MessageBox is safe — see WinForms: SessionEnded on the UI
thread below
StartHeartbeat() is not optional bookkeeping — it is the enforcement point
- The server drops a session that stops beating, so a client that never beats loses nothing but also never learns it was revoked
- Every beat re-checks the license: ban, pause, expiry, HWID reset, deletion and maintenance mode all end the session on the very next beat
- If the server is unreachable for
MaxHeartbeatFailuresbeats in a row (3 by default), the client ends the session itself withNETWORK_LOST. Without that, blocking the license domain in a firewall would keep the application running forever - A beat answered with a plain, unencrypted "success" did not come from the license server, so it counts as a failed beat — a proxy answering in the server's place cannot keep the application running either
Every request is timestamped, and the server refuses one that is more than five minutes
off — with a plain, unencrypted error, because it cannot verify the request. Before 1.1.0
the heartbeat shrugged those refusals off, so moving the system clock after signing in kept
the application running and deaf to bans. Now only an encrypted reply counts as an answer:
MaxHeartbeatFailures refusals in a row end the session with CLOCK_SKEW, whose message
asks the user to correct the date and time (other plain refusals end it with
NETWORK_LOST). Plain 429 Too Many Requests replies get a larger budget,
MaxRateLimitedBeats (10 by default), so users behind a busy shared IP are not signed out
within a minute — but a proxy answering 429 forever still ends the session
Since 1.2.0 the client first tries to repair the clock: the server's refusal carries its own
time, the client shifts its timestamps by the difference and sends the beat once more
(AutoCorrectClock, on by default). Bans still arrive, because the corrected beat gets a
real, encrypted answer. A session ends with CLOCK_SKEW only when that does not help, or
with AutoCorrectClock = false
StartHeartbeat() remembers the SynchronizationContext it was called on. Call it from
the UI thread — for example right after await client.LoginAsync(...) in a button
handler — and SessionEnded is raised back on that thread, so the handler can update
controls directly, without Invoke/BeginInvoke (WPF: without the Dispatcher)
using PWFAuth;
public partial class MainForm : Form
{
private readonly PwfClient _client = new PwfClient("your-64-char-app-secret");
public MainForm()
{
InitializeComponent();
_client.SessionEnded += (s, e) => OnSessionEnded(e);
}
private async void btnSignIn_Click(object sender, EventArgs e)
{
btnSignIn.Enabled = false;
var login = await _client.LoginAsync(txtKey.Text.Trim());
btnSignIn.Enabled = true;
if (!login.Success)
{
lblStatus.Text = login.Message;
return;
}
panelApp.Enabled = true;
lblStatus.Text = "Signed in";
_client.StartHeartbeat(); // called on the UI thread → SessionEnded comes back to it
}
// Runs on the UI thread: touching controls here is safe.
private void OnSessionEnded(SessionEndedEventArgs e)
{
panelApp.Enabled = false;
lblStatus.Text = e.IsNetworkFailure
? "Lost the connection to the license server. Please sign in again."
: e.Message;
}
protected override void OnFormClosed(FormClosedEventArgs e)
{
_client.Dispose();
base.OnFormClosed(e);
}
}VB.NET:
Private ReadOnly client As New PwfClient("your-64-char-app-secret")
Private Sub MainForm_Load(sender As Object, e As EventArgs) Handles MyBase.Load
AddHandler client.SessionEnded, Sub(s, args)
' Already on the UI thread — no Invoke needed.
panelApp.Enabled = False
lblStatus.Text = args.Message
End Sub
End Sub
Private Async Sub btnSignIn_Click(sender As Object, e As EventArgs) Handles btnSignIn.Click
Dim login = Await client.LoginAsync(txtKey.Text.Trim())
If Not login.Success Then
lblStatus.Text = login.Message
Return
End If
panelApp.Enabled = True
client.StartHeartbeat() ' UI thread → SessionEnded is raised on it
End SubOnly the event is marshalled — the beats themselves never run on the UI thread. Where there
is no UI context (console apps, services, a background thread) the event is raised on the
heartbeat's own thread, as in 1.0. Set RaiseEventsOnCapturedContext = false in
PwfClientOptions to always get that behaviour
A successful LoginAsync carries the whole licence state, so you rarely need a second
call to show the user what they have
| Field | Type | Notes |
|---|---|---|
user.license_key |
string | The key that was activated |
user.key_type |
string | days, hours, lifetime, … |
user.duration |
number | Length in units of key_type |
user.hwid |
string | The machine this session is bound to |
user.activated_at |
string | UTC ISO 8601, first activation |
user.expires_at |
string | null | UTC ISO 8601. null means lifetime |
user.days_remaining |
number | null | null for lifetime keys |
user.status |
string | active |
features |
object | Per-key feature flags you set in the dashboard |
app.name / app.version / app.message |
string | Your app's name, current version, login message |
texts / slides |
object / array | Remote strings and announcement slides |
session_id |
string | Also held internally by the client |
heartbeat_interval |
number | Seconds; StartHeartbeat() already honours it |
expires_at and days_remaining are null on lifetime keys — always check before
formatting, or a lifetime customer sees a crash instead of "never expires"
using System.Text.Json;
if (login.TryGetProperty("user", out JsonElement user))
{
string? key = user.GetProperty("license_key").GetString();
string? status = user.GetProperty("status").GetString();
// The ValueKind check is required, not defensive padding: on .NET Framework
// TryGetDateTime *throws* on a JSON null instead of returning false, and
// expires_at is null for every lifetime key.
if (user.TryGetProperty("expires_at", out JsonElement exp)
&& exp.ValueKind == JsonValueKind.String
&& exp.TryGetDateTime(out DateTime expiresUtc))
{
int left = user.TryGetProperty("days_remaining", out JsonElement days)
&& days.ValueKind == JsonValueKind.Number ? days.GetInt32() : 0;
Console.WriteLine($"{key} — expires {expiresUtc:yyyy-MM-dd HH:mm} UTC, {left} day(s) left");
Console.WriteLine($"Local time: {expiresUtc.ToLocalTime():g}");
}
else
{
Console.WriteLine($"{key} — lifetime licence ({status})");
}
}
// Feature flags — whatever you defined for this key in the dashboard
foreach (var f in login.GetStringMap("features"))
Console.WriteLine($"{f.Key} = {f.Value}");VB.NET — the same thing for a WinForms label:
Imports System.Text.Json
Dim user As JsonElement
If login.TryGetProperty("user", user) Then
Dim expEl As JsonElement, daysEl As JsonElement
Dim expiresUtc As DateTime
' The ValueKind check is required: on .NET Framework TryGetDateTime throws
' on a JSON null rather than returning False, and lifetime keys send null.
If user.TryGetProperty("expires_at", expEl) AndAlso
expEl.ValueKind = JsonValueKind.String AndAlso
expEl.TryGetDateTime(expiresUtc) Then
Dim left As Integer = 0
If user.TryGetProperty("days_remaining", daysEl) AndAlso
daysEl.ValueKind = JsonValueKind.Number Then left = daysEl.GetInt32()
lblLicense.Text = String.Format("Expires {0:yyyy-MM-dd} — {1} day(s) left",
expiresUtc.ToLocalTime(), left)
If left <= 7 Then lblLicense.ForeColor = Color.OrangeRed ' nudge them to renew
Else
lblLicense.Text = "Lifetime licence" ' no expiry to show
End If
End If
' Gate a feature on a per-key flag
Dim features = login.GetStringMap("features")
btnProExport.Enabled = features.ContainsKey("pro_tier") AndAlso features("pro_tier") = "True"Nothing here is cached by the client — re-read it from the login response you already
hold, or call CheckKeyAsync(key) later for a fresh read without consuming a seat
A key binds to the machine that activates it. When a customer replaces their PC,
LoginAsync on the new one fails with HwidMismatch (or DeviceLimit for a multi-device
key that is already on all its machines). ResetHardwareIdAsync unbinds the key on the
spot — no ticket, no waiting for you — and the next LoginAsync binds it to this machine
var login = await client.LoginAsync(key);
if (!login.Success &&
(login.ErrorCode == PwfErrorCodes.HwidMismatch || login.ErrorCode == PwfErrorCodes.DeviceLimit))
{
Console.WriteLine(login.Message);
Console.Write("Move your license to this PC? [y/N] ");
if (string.Equals(Console.ReadLine()?.Trim(), "y", StringComparison.OrdinalIgnoreCase))
{
var reset = await client.ResetHardwareIdAsync(key, "Moved to a new PC");
Console.WriteLine(reset.Message); // written for the end user, success or not
if (reset.Success)
login = await client.LoginAsync(key); // binds the key to this machine
}
}
if (!login.Success)
{
Console.WriteLine(login.Message);
return;
}
client.StartHeartbeat();When the reset is refused, reset.ErrorCode says why:
| Code | Meaning |
|---|---|
RATE_LIMITED |
The key was reset recently; the message says how many hours to wait |
SELF_RESET_DISABLED |
You turned self-service resets off for this application |
KEY_NOT_ACTIVE |
The key is expired, banned or paused |
NO_HWID |
The key is not bound to any machine — nothing to reset |
INVALID_KEY |
No such key for this application |
Resets follow your application's policy: you can turn them off, and you choose the
cooldown between two resets (12 hours by default). On success,
reset.GetString("next_reset_at") (UTC, ISO 8601) says when the next one is allowed. A
reset unbinds every device of the key and ends all of its sessions, so the old PC is signed
out at its next heartbeat
Logging out is not a reset: LogoutAsync ends the session, but the key stays bound to the
machine
Customers buy a key, create an account with it, and later add more keys to the same account to extend it: one username and password, no pile of keys to keep. Turn on Sign-up needs a license key in App Settings > Account sign-up to make the key mandatory at sign-up
// Sign up: the account gets the key's time and device limit; the key is used up.
var signup = await client.RegisterAccountWithKeyAsync("alice", "s3cret-pass", key, "alice@example.com");
if (!signup.Success) { Console.WriteLine(signup.Message); return; }
// Later: add another key, with the password (works even if the account has expired) ...
var added = await client.RedeemKeyAsync("alice", "s3cret-pass", newKey);
// ... or from a program that is signed in with AccountLoginAsync.
var added2 = await client.RedeemKeyAsync(newKey);
Console.WriteLine(added.Message); // "Key added: +30 days. ..."
Console.WriteLine(added.GetInt32("days_remaining", 0));In App Settings you choose whether a key's time goes on top of the time left or
starts again from now, and whether the account keeps its own device limit or
takes the key's. A used key cannot sign in on its own (LoginAsync answers
KEY_REDEEMED), and banning it in the dashboard takes its time back from the account
| Code | Meaning |
|---|---|
KEY_REQUIRED |
This application only creates accounts with a key |
KEY_ALREADY_USED |
The key was already added to an account |
KEY_IN_USE |
The key was activated by a license login, so it cannot go to an account |
INVALID_KEY |
No such key for this application |
ALREADY_LIFETIME |
The account already has lifetime access |
INVALID_CREDENTIALS |
Wrong username or password (counts toward the sign-in lockout) |
var status = await client.CheckKeyAsync(key); // no session, no seat consumed
var info = await client.GetAppInfoAsync(); // name, version, download URL, socials
var texts = await client.GetTextsAsync(); // remote strings, per-key overrides
var slides = await client.GetSlidesAsync(); // announcement slides
await client.SetVariableAsync("theme", "dark"); // a value kept on the server for this customer
var theme = await client.GetVariableAsync("theme"); // null when never set; also GetVariablesAsync, DeleteVariableAsync
var update = await client.CheckUpdateAsync("1.4.2"); // OTA: version, sha256, download URL
var trial = await client.CreateTrialAsync(); // free trial for this machine
var moved = await client.ResetHardwareIdAsync(key); // free the key for a new PC
await client.LogoutAsync(); // end the session; the key stays bound here
// User accounts (the username/password half of the platform)
await client.RegisterAccountAsync("alice", "s3cret", "alice@example.com");
await client.RegisterAccountWithKeyAsync("bob", "s3cret", key); // account from a license key
await client.AccountLoginAsync("alice", "s3cret");
await client.RedeemKeyAsync("alice", "s3cret", newKey); // add a key's time to the accountEndpoints this client does not wrap yet are still reachable — PostEnvelopeAsync,
GetEnvelopeAsync and PostPlainAsync are public, and CryptoEnvelope is too. The two
envelope transports refuse a plain reply that claims success, like every wrapped call
PwfResponse exposes Success, ErrorCode and Message, plus typed getters and the
raw JsonElement for endpoint-specific fields:
if (!login.Success && login.ErrorCode == PwfErrorCodes.HwidMismatch)
ShowHardwareResetDialog();
var welcome = (await client.GetTextsAsync()).GetStringMap("texts")["welcome_message"];
var check = await client.CheckUpdateAsync("1.4.2");
if (check.GetBoolean("update_available", false) &&
check.TryGetProperty("update", out var upd))
{
Console.WriteLine(upd.GetProperty("version").GetString());
}HardwareId.Get() reads the Windows cryptography MachineGuid, /etc/machine-id on
Linux, or the platform UUID on macOS, and falls back to the machine name. It does not
use wmic, which was removed in Windows 11 24H2. Override it when you need a different
binding policy:
var client = new PwfClient(new PwfClientOptions
{
AppSecret = secret,
HardwareId = myOwnFingerprint,
BaseUrl = "https://pwfauth.com",
});Every failure to get a usable reply is a PwfHttpException. StatusCode 0 means no reply at
all (no connection, DNS, a firewall, a proxy, TLS, or no answer within Timeout); the network
error is the InnerException. Any other value is the HTTP status of a reply that was not the
API's JSON (a CDN error page, a wrong base URL)
Every request carries User-Agent: PWFAuth-dotnet/1.3.1 (+https://pwfauth.com) — the
version follows the package — so SDK traffic is easy to tell apart in logs and firewalls.
Headers are set on each request, never on the HttpClient. One you pass to
new PwfClient(options, httpClient) (from IHttpClientFactory, or with a proxy
configured) is used exactly as it is: the client never changes its headers or its
Timeout and never disposes it. Set its Timeout yourself — PwfClientOptions.Timeout
(15 seconds by default) applies only to the HttpClient the client creates for itself
The app secret ships inside your binary — that is inherent to the envelope protocol, which needs the key on the client to encrypt. Treat compiled output as sensitive: obfuscate release builds, and keep the secret out of public source control (read it from a developer-controlled configuration before distribution). Anyone holding the secret can call the API as your application
After verifying the independent server signature, the SDK also requires encryption on session and content endpoints; only refusals
that happen before it can verify the request (a bad secret, a rate limit, a clock too far
off) come back as plain JSON. A plain reply that claims success therefore did not come
from PWF Auth — LoginAsync and the other encrypted calls throw PwfSecurityException
rather than trust it, and the heartbeat treats it like an unreachable server. Catch it
where you catch PwfHttpException, and never unlock the application on it
MIT © PWF Auth
Custom HttpClient instances are trusted application code: configure them to reject redirects and validate TLS certificates. The SDK cannot inspect an injected transport's internal handler. Independent response signature verification still applies