Countersign

Set up Countersign

From a new account to a game only genuine copies can join.

Install the package

You need Unity 2021.3 or newer and PUN 2. Add the Meta XR Platform SDK as well if your game is on the Meta Horizon Store.

Download the Unity package 0.1.0

  1. Save the file in your project's Packages folder and commit it, so everyone on your team gets the same one.
  2. In Unity, open Window, then Package Manager.
  3. Press +, then Add package from tarball, and pick the file.

SHA-256 of this file: d3ad95d5a65a2425fb544829efbed5eaaeb00bddfd2c9a719b8443cdb897b096

Choose who may play

Make an account and add your game. The most important choice on that form is which builds may play:

ChoiceUse it forYou'll need
Testing onlyThe Unity editor and your own headset. Any copy gets in, so never ship with this.Nothing
APK buildsGames shared through SideQuest or a Discord.Your package name and the SHA-256 of your release signing certificate
APK builds, strictAs above, and Meta must give the headset its highest rating. Confirmed on a Quest 3 under the developer's own account, so try it with a player's account from outside your team before you ship: if Meta won't answer for them, they can't get in.As above, your Meta app credentials, and Meta's Platform SDK in your build
Store buildsGames on the Meta Horizon Store.Your package name and Meta app credentials
Store builds, strictAs above, and Meta must also vouch for the headset itself.As above

To get your certificate's SHA-256, run apksigner verify --print-certs your.apk on a release build. Meta app credentials look like OC|app id|app secret and come from the Meta developer dashboard.

Sign players in

This signs the player in, reads from PlayFab through Countersign, and connects to Photon. Leave out the PlayFab lines if you don't use it.

using Countersign;
using Countersign.Pun;
using Countersign.Unity;
using UnityEngine;

public class Online : MonoBehaviour
{
    const string AuthServer = "https://api.countersignauth.com";
    const string GameId = "your game id, from the game's Overview";

    async void Start()
    {
        CountersignClient countersign = CountersignUnity.CreateClient(AuthServer, GameId, sideload: true);
        var playfab = new CountersignPlayFab(countersign);
        try
        {
            PlayFabLogin login = await playfab.LoginAsync();
            string inventory = await playfab.CallAsync("GetUserInventory");
            await CountersignPun.ConnectAsync(countersign);
        }
        catch (CountersignException e)
        {
            Handle(e);
        }
    }

    void Handle(CountersignException e)
    {
        switch (e.Code)
        {
            case ErrorCode.Banned:
                Debug.Log("Banned: " + e.BanReason);
                break;
            case ErrorCode.Denied:
                Debug.Log("This copy of the game or this headset can't play online.");
                break;
            case ErrorCode.PlayFab:
                Debug.Log("PlayFab said " + e.PlayFabError);
                break;
            default:
                if (e.IsRetryable) Debug.Log("Try again in " + e.RetryAfterSeconds + " s");
                else Debug.LogError(e);
                break;
        }
    }
}

That's all the session code you need. The client renews passes while the player plays and recovers from most errors by itself.

sideload: true is for APK builds. For a store build, pass Meta's provider instead:

// initialize the Meta Platform SDK first, then:
var countersign = CountersignUnity.CreateClient(AuthServer, GameId, new Countersign.Meta.MetaIntegrityProvider());

APK builds should use Meta too. Add your Meta app credentials in Settings and Meta's Platform SDK to your build, then pass the provider as well as sideload: true. Meta then rates each headset and gives it an id, and its own device bans become available. If Meta won't answer for a player, they still get in on their headset's hardware key. Install com.meta.xr.sdk.platform from Meta's package registry, call Core.AsyncInitialize with your App ID first, and on Unity 2021.3 use version 72.0.0 and turn on the built-in Audio module.

Bans are on the headset. The client sends hashes of two ids the headset gives any app, one for each game, and the server only believes them from a build it has proved genuine. A ban covers every player seen on that headset, and lifting it lifts it for all of them, so a family sharing one headset shares its bans. A banned account signing in on someone else's headset bans that headset and whoever plays on it, too, so tell your players not to lend or borrow. It goes one step: their other headsets, and the people on them, aren't touched. Say in your privacy policy that you keep a hashed device identifier to enforce bans. Pass deviceMarks: false to leave them out, and a ban then relies on Meta's id, which changes every 30 days.

A ban can follow the Meta account too. Pass account: new Countersign.Meta.MetaAccountProvider() to CreateClient, once your app has the User ID grant in Meta's Data Use Checkup. Meta confirms each proof, so nobody can claim an account that isn't theirs. A banned account is then refused on any headset, and a player who resets their headset or buys a new one keeps their progress. Only a hash of the account's id is kept. Turn on Require proof of the player's Meta account in your settings if a player shouldn't get in without one.

Reinstalling keeps the player. The same headset and Meta profile comes back as the same player, with the same PlayFab account. A factory reset starts them over.

Reconnecting to Photon? Use CountersignPun.ReconnectAndRejoinAsync, or call CountersignPun.PrepareAsync before your own reconnect. Each proof works only once, and Photon checks again whenever its own token runs out.

Test in the editor

Press Play and the client works, using a test key saved on your computer. A test key can't prove anything about an install, so the editor only gets in while the game is set to Testing only. Under any other choice it gets Denied, which is the check doing its job.

Players who signed in during testing start over as new players when you change the policy, because a test key doesn't count under a real one.

Once your game is live, add a second game set to Testing only and point your editor at its id. Every plan has room for it, and the live game stays locked.

Build for Quest

In Project Settings, under Player and Android:

  • Scripting Backend: IL2CPP, with ARM64 as the target architecture, as Quest requires.
  • Minimum API Level: 29, the level Quest apps need.
  • Internet Access: Require.

The package adds the keep rule its Java code needs, so minified builds work without anything extra.

Sign release builds with the same key whose certificate you gave Countersign. A build signed with any other key is treated as a modded copy.

Test on a real headset. Hardware checks are confirmed on a Quest 3 so far, so if you support other headsets, sign in on each of them under your real policy before you ship. An Android emulator's keystore can't prove anything about the device, so an emulator only gets in under Testing only.

Photon

In the Photon dashboard, for your app:

  1. Add a Custom Server authentication provider with the address on your game's Overview page.
  2. Switch off Allow anonymous clients to connect. While it's on, anyone can skip Countersign.
  3. Turn on EnableCloseConnection if you use RoomGuard.
  4. Optional: add webhooks. Turn them on in your game's Settings to get the address. Set PathCreate to Create, and PathBeforeJoin to BeforeJoin if your plan offers it. They turn away a player banned mid-session at their next room.

Photon gets each player's id from Countersign and enforces it, so the UserId you see in a room is the id to ban.

RoomGuard removes banned players from a room while they're in it. Add the component to an object that lasts the whole online session, fill in your auth server, game id and the signing key from your game's Overview page, and make rooms with CountersignPun.Guard(new RoomOptions { ... }). Paste the key in rather than fetching it, so a fake server can't hand over its own.

PlayFab

  1. Add your title id and secret key in your game's Settings. The key stays on the server and never goes in the game.
  2. List every Client API your game calls, such as GetUserInventory. For CloudScript, list each function as ExecuteCloudScript:GrantDaily. Logins and currency grants are refused even if listed, so anything that gives a player something has to be a CloudScript function you wrote.
  3. Lock down logins on the PlayFab tab, so nobody can make accounts from their own code. PlayFab can take about six minutes to apply this everywhere. Check with PlayFab shows what it does right now.

By default the PlayFab session stays on the server, and only the calls you listed can run. If your game already uses the PlayFab SDK everywhere, choose session mode and hand over the session like this, then restrict the title with PlayFab's own API policies:

PlayFabLogin login = await playfab.LoginAsync();
PlayFabSettings.staticPlayer.PlayFabId = login.PlayFabId;
PlayFabSettings.staticPlayer.ClientSessionTicket = login.SessionTicket;
PlayFabSettings.staticPlayer.EntityToken = login.EntityToken;
PlayFabSettings.staticPlayer.EntityId = login.EntityId;
PlayFabSettings.staticPlayer.EntityType = login.EntityType;

Bans and moderators

A ban reaches Countersign, PlayFab, the next room join and, with RoomGuard, the room the player is in. Ban from your game's Players tab, or give your moderators slash commands in your own server on the Discord tab: /ban, /unban, /player, /refusals and /status. Alerts about refusals can go to a channel too, from Settings.

When someone is refused

Players are told as little as possible, so cheaters learn nothing from the answer. Your game's Refusals tab shows you the real reason for each one.

The client getsWhat it meansWhat to do
DeniedNot a genuine copy or headset under your policy.Read the Refusals tab. On your own build, the usual cause is a wrong package name or certificate.
BannedThis player is banned.Show BanReason and BannedUntil.
PausedYou paused the game, or its plan has run out.Resume it on the Overview page, or check Billing.
UnknownGameThe game id in your build is wrong.Copy it again from the Overview page.
NotAllowedThat PlayFab call isn't on your list.Add it in Settings if the game should make it.
NotEnabledPlayFab isn't turned on for this game.Turn it on in Settings.
PlayFabPlayFab ran the call and said no.Read PlayFabError, such as InsufficientFunds.
RateLimited, Upstream, ServerError, Network, IntegritySomething was busy or couldn't be reached.Try again after RetryAfterSeconds.
DeviceKeyThe headset's keystore failed.Report it with the log.

Your own tools

A game key from the Keys tab lets your scripts moderate one game without your password. Send it as the X-Game-Key header to https://api.countersignauth.com/manage/v1/your-game-id/...

RequestDoes
POST ban{"subject": "player id", "hours": 72, "reason": "speed hacking"}. Leave out hours for a permanent ban.
POST unban{"subject": "player id"}
GET bansEveryone banned from this game.
GET players/player-idOne player's headset check, trust level and ban.
GET decisions?limit=50The latest refusals and why.
GET usagePlayers this month and totals.

Your account

Turn on two-step sign-in on your Account page. Your account holds your PlayFab secret key and can ban your players, so it shouldn't rest on a password alone. It works with any authenticator app.

Signing up gives you a recovery code, shown once. Keep it in your password manager. With it and your email you can set a new password from the forgot password page, which also signs out every other device and gives you a new code. A used code stops working. If you lose your phone, enter the recovery code where the login asks for your app's code. Either way, two-step sign-in turns off until you set it up again.

If you've lost your code but can still log in, make a new one on your Account page. If you lose both your password and your code, nobody can get you back in, because this site doesn't send email.