Account linking#
Account linking binds a Minecraft UUID to a Discord user ID. The binding is what
lets a Discord member act as their in-game character — today that means the
native /home name command, which runs a Minecraft command as the linked player.
Nothing about linking is required for chat bridging. A server that never enables it still relays chat and events normally.
The handshake#
Both halves are six digits. Nothing is transferred except the code.
- In game, the player runs
/viscord discord link. - The server generates a fresh six-digit code with
SecureRandomand replies:
Your link code is: 123456
Use /link 123456 in Discord to link your account.
Code expires in 5 minutes.
- In Discord, in the configured channel, the player runs
/link code:123456(native) or sends/link 123456as a message (legacy). - The binding is written and both sides report success:
Successfully linked <player> to Discord user <name>.
The pending code lives only in memory, keyed by the code string. It is not written to disk, so a server restart between step 2 and step 4 invalidates every unredeemed code. That is a feature: an interrupted handshake should be retried from the start, not completed hours later.
Configuration#
[account_linking]
enabled = true
code_expiry = 300
| Key | Type | Default | Range |
|---|---|---|---|
enabled |
bool | true |
— |
code_expiry |
int (seconds) | 300 |
60–600 |
code_expiry is clamped to 60–600 on load. The in-game message converts it to
whole minutes for display, so 300 shows as 5 minutes and 90 shows as
1 minutes — a cosmetic rounding of the same underlying value.
Setting enabled = false does not just hide the command. Three paths check it
independently: the in-game link branch, the in-game unlink branch, and the
Discord-side handlers. With linking off, the accounts manager is never even
constructed.
What is persisted#
Bindings are stored at:
config/viscord/viscord-links.json
The file is a JSON map keyed by Minecraft UUID. Each entry carries the UUID, the Minecraft username at link time, the Discord ID, the Discord display name at link time, and the link timestamp:
{
"069a79f4-44e9-4726-a5be-fca90e38aaf5": {
"minecraftUUID": "069a79f4-44e9-4726-a5be-fca90e38aaf5",
"minecraftUsername": "ExamplePlayer",
"discordId": "123456789012345678",
"discordUsername": "example",
"linkedTimestamp": 1758000000000
}
}
Reads and writes are pinned to UTF-8 explicitly. On a platform whose default charset is not UTF-8 — Windows with a legacy code page, most often — a player name with non-ASCII characters would otherwise corrupt on write.
The snapshot is serialised on the calling thread under the lock so it is a
consistent point-in-time view, then the disk write is handed to the async
executor. That keeps a /viscord discord unlink in a command handler from
touching the tick thread with file I/O.
Unlinking#
/viscord discord unlink removes the binding for the executing player and saves.
There is no Discord-side unlink command and no operator force-unlink command.
To remove a binding without the player present, stop the server and edit
viscord-links.json by hand, removing the whole entry for that UUID. Then start
the server. Editing while the server is running races the autosave-backed
in-memory map and your change will be overwritten.
Security properties#
| Property | How it is achieved |
|---|---|
| Code unpredictability | SecureRandom, formatted %06d across a 1,000,000-key space. |
| Code uniqueness | ConcurrentHashMap.putIfAbsent loops until an unused code is found, so two players cannot be handed the same code. |
| Check-and-bind atomicity | Both the UUID-already-linked and Discord-already-linked checks and the insert happen inside one synchronized block on the account map. Two simultaneous redemptions cannot both succeed. |
| One binding per side | A UUID with a binding is rejected; a Discord ID with a binding is rejected. The code is consumed on rejection so it cannot be retried. |
| Encoding safety | FileReader / FileWriter pinned to StandardCharsets.UTF_8. |
| Code expiry | Each pending code carries an absolute expiry; expired entries are swept at the start of every generate and verify call. |
| Brute-force resistance | Sliding-window per-user and global limits on /link, checked before any lookup — see Configuration. |
| Input strictness | ^\d{6}$ exactly. Anything else — five digits, seven, letters, punctuation — gets one generic error, so a reply cannot distinguish "malformed" from "not found". |
| Secret exposure | The code is returned to the requesting player only, and never broadcast. Discord-side failures reply ephemerally on the native path. |
Two things are worth stating plainly rather than leaving implied:
The code is single-use. Redemption removes it from the pending map on both success and failure. A wrong guess burns the code, so a legitimate player who misreads their own code has to generate a new one. This is why the in-game message puts the code on its own line.
A code in flight is a shared secret. Anyone who can read the Minecraft chat
where the code was printed, or the Discord channel where it was typed, can
complete the link. Generate it in a context only the player sees, and prefer the
native /link code form, whose reply is ephemeral.
Operator notes#
- Back the file up.
viscord-links.jsonis not covered by server-world backups that only archiveworld/. If bindings matter, includeconfig/viscord/in the backup set. There is no way to reconstruct a binding from the Discord side; the mod stores no reverse index on disk. - There is no force-bind and no force-unbind. An operator cannot create a binding for a player, and cannot clear one except by editing the file. Plan for the case where a player has lost access to their Discord account: they need the file edited, not a command.
- Deleting a binding does not delete anything in Discord. It only removes the
ability to run
/homeas that player. - Re-linking after an unlink generates a new code and produces a new entry. The old timestamp is not carried over.
- The file grows monotonically in the sense that entries are only removed by an explicit unlink or a manual edit. It is small — roughly 250 bytes per linked player — so this is not a practical concern below thousands of members.
- Restarting invalidates pending codes but not bindings. Bindings are on disk; codes are not.
Failure messages and what they mean#
| Message | Surface | Cause |
|---|---|---|
Account linking is disabled. |
In game | account_linking.enabled = false. |
Already linked to <name> <@id> |
In game | This UUID already has a binding. |
Discord bot is not connected. Please contact an administrator. |
In game | The platform is not running, so no code could be redeemed. |
Failed to generate link code. You may already have an account linked. |
In game | Catch-all after the specific checks. |
Account linking is disabled or unavailable. |
Discord native | Linking off, or the accounts manager failed to construct. |
Too many link attempts. Please wait before trying again. |
Discord native | Rate limit hit on /link. |
Invalid link code. Use the six-digit code from /viscord discord link. |
Discord native | Format rejection, before any lookup. |
Invalid or expired link code! |
Both | No pending code with that value, or it aged out. |
This Minecraft account is already linked to Discord user <name> |
Discord | The code's UUID gained a binding between generation and redemption. |
This Discord account is already linked to Minecraft player <player> |
Discord | This Discord ID is already bound to a different UUID. |
The legacy text trigger is silent on a rate limit, so a throttled user sees no reply at all. That is intentional — see Commands.