Passwords
Argon2id, 64 MB, t=3, p=1, per-user salt. Parameters stored beside the hash so cost can be raised later.
Devices
An Ed25519 keypair per machine, protected by the OS key store, proving itself by signature on every sign-in.
Tokens
15-minute ES256 access tokens, 30-day opaque refresh tokens that rotate on every use.
Tickets
90 seconds, single use, bound to one node and one WireGuard key. Redeemed online or not at all.
A fingerprint you send me is not a fact
The obvious way to limit an account to one machine is to ask the client for a device fingerprint and trust it. That is worth nothing: whoever wants two devices sends the same string from both, and whoever wants to share an account posts the string to a friend.
So the fingerprint here is a label, shown in the console so you can tell your laptop from your phone. The binding is a key.
- The client generates an Ed25519 keypair on first sign-in and stores the private half in the platform key store — DPAPI or a CNG/TPM container on Windows
- The public half is registered against the account, with a name and platform
- Every later sign-in is challenge–response: the master issues a nonce, the client signs it, the master verifies against the stored key
- Signing covers the decoded nonce bytes, and a fixed vector is asserted from both the .NET and the Dart side so the two languages cannot drift apart
Running a second machine now means either extracting a key the OS is protecting, or asking an administrator to raise the device limit. That is a boundary rather than a speed bump.
Four properties, all of them tested
A ticket is the thing a node accepts. It is worth stating exactly what it proves, because each property closes a specific attack.
Single use
Redemption is an atomic compare-and-set on the ticket row. Two simultaneous redemptions: one wins, the other is refused. A ticket captured on the wire and replayed a second later is already spent.
Node-bound
A ticket issued for node A is refused when node B presents it. A hostile node cannot harvest tickets and use them elsewhere.
Key-bound
The master records the client's WireGuard public key when it issues the ticket, and the node must present that same key at redemption. An intercepted ticket cannot be redeemed for an attacker's key.
Short-lived
90 seconds — long enough for a slow network, short enough that a stolen ticket is nearly useless. Expiry is judged by the master; the node's own check is advisory and tolerant of clock skew.
Signed as well as random
The opaque secret is what matters, but the ticket is wrapped in a signed envelope carrying the node id and expiry so a node can drop obvious garbage without a round trip. That is denial-of-service protection, not authorization — the online redemption stays mandatory.
One refusal, one code
Expired, wrong node, already redeemed and key mismatch all answer identically. Telling them apart would turn the node into an oracle that reports which half of a guess was right — and there is no operational question the distinction answers that the master's own logs do not.
Short lives, and rotation with teeth
Access tokens last fifteen minutes and are signed ES256 by a key that
rotates, published through JWKS so validation never needs a shared
secret. The algorithm is pinned, so a token offering
alg:none or a symmetric algorithm against a published
key is refused rather than considered.
Refresh tokens are opaque 256-bit values, stored only as hashes, bound to the device, and rotated on every use. If a spent one is presented again — the signature of a stolen token being used alongside the real client — the entire token family for that device is revoked and the event is written to the audit log.
- Clock skew tolerance is 30 seconds, not the framework's default five minutes, which would quietly extend every token by a third
- Signing keys are encrypted before they reach the database, with a key that never lives beside it
- Key refresh preserves identity, so a rotation cannot break validation mid-flight
What each compromise actually costs
A stolen password
Not enough. The device signature is a second, independent proof, and enrolling a new device is refused past the account's limit.
A stolen access token
Fifteen minutes, and it cannot mint a ticket for a device it is not bound to.
A stolen refresh token
One use, then the reuse is detected and the family dies. The real client's next refresh is what triggers it.
An intercepted ticket
Ninety seconds, one node, one WireGuard key — the attacker's own key will not redeem it.
A compromised node
It sees the traffic of the clients on it, which is inherent to any VPN. It holds no credentials, has no database access, cannot mint a session, and cannot redeem a ticket bound to another node. Its certificate is revoked centrally and stops working at the TLS layer.
A compromised master
Total, and stated plainly rather than hedged. Mitigated by keeping the console off the open internet, by administrator two-factor, and by an append-only audit log that records every administrative action with actor, target and source address.
Read the connection path next
The security model is easiest to believe once you have followed a single connection from sign-in to tunnel.