View as Markdown

How to implement silent social sign-in

Nakama supports a range of authentication methods: device ID, email, various social providers, and custom identifiers. Each one returns a session token that gives the client access to the server, and each resolves to a single Nakama account.

This guide walks through the silent sign-in flow on mobile and how to associate a player’s social profiles with their Nakama account. Players already signed into Game Center on iOS or Google Play Games on Android go straight into the game with no login screen. A stored device ID keeps everyone else playing until a social profile can be linked, and players can link additional providers, such as Facebook or Steam, from a settings menu.

In this guide #

You’ll start with how IDs map to a Nakama account, then move on to a step-by-step implementation, conflict resolution, and secure device-ID storage.

Before you start #

You’ll need:

  • Nakama (and optionally Hiro): Nakama 3.16.0 or later (required for Play Games v2 auth code exchange)

  • Unity: 2022.3 or later (required by the Apple Unity plugins)

  • iOS / Android: Apple.GameKit and Apple.Core plugins from apple/unityplugins , iOS 15.6+. / Google Play Games plugin for Unity v2.x, IL2CPP scripting backend

  • Configurations for silent sign-in providers:

You’ll see the following terms used throughout the guide:

  • Social ID: an identifier from a social provider such as Google Play Games, Game Center, Facebook, Steam.
  • Device ID: a device identifier used to register a player with the server. More in Store the device ID securely .
  • Linked: a social ID or device ID is associated with a Nakama account.

How social sign-in resolves to a Nakama account #

A Nakama account is one server-side record with many IDs linked to it. An account can hold several device IDs at once, but only one social ID per provider: one Game Center player, one Google player, one Facebook profile.

Nakama Console authentication panel showing one account with three device IDs and a linked Game Center ID
One Nakama account: several device IDs, one slot per social provider

Any one of these IDs is enough to sign the player into this account, from any device.

Ways to associate a social provider #

Nakama supports a number of services with register and login. With each provider, you obtain an OAuth or access token from the social service and pass it to Nakama. Nakama then fetches the player’s account from that service and uses it to set up the Nakama user.

Two operations manage the IDs on an account: authenticate gets the player in, and link attaches another ID to the account they’re already in. They combine into more than one valid pathway, depending on which ID the player has available first:

  • Device first, social ID linked later: authenticate with a device ID first, then link a social ID once the player signs in with one, from a settings menu or an in-game prompt.
  • Social ID first, device ID linked after: authenticate with a social provider such as Game Center or Google Play Games, then link the device ID so the same fallback resolves to this account on future launches.
AspectAuthenticateLink
What it doesFinds the Nakama account that owns this ID, or creates one if none existsAttaches an additional ID to the Nakama account you’re already signed into
ReturnsA session (auth token and refresh token)Nothing (Task). The current session is unchanged
Example API (C#)AuthenticateGameCenterAsync , AuthenticateGoogleAsyncLinkDeviceAsync , LinkGoogleAsync , LinkGameCenterAsync

Silent sign-in flow #

On mobile, the Social ID first, device ID linked after approach means trying to log into Game Center on iOS or Google Play Games on Android first. When the player is already signed into either one at the OS level, that sign-in is silent, with no on-screen prompt. This silent sign-in apporach provides a seamless experience for the players.

On a first game launch, the flow runs like this:

Flowchart of the first-launch silent sign-in flow: attempt silent Game Center or Play Games sign-in, fall back to device authentication when it fails, then link the stored device ID to the resulting Nakama account.
First launch: attempt silent social sign-in, fall back to device authentication, then link the stored device ID.
Step 1

Run silent sign-in #

Silent sign-in runs in two steps, and works the same way on Game Center and Play Games:

  1. Sign in at the OS level to obtain a provider credential: an identity-verification signature from Game Center, or a single-use server auth code from Play Games. This step is silent when the player is already signed into the platform. If they aren’t, the provider may present its own sign-in UI.
  2. Exchange that credential with Nakama. Call the matching Authenticate*Async. Nakama verifies the credential with the provider, resolves it to the Nakama account that owns the social ID (creating one on first sign-in), and returns a session .

Game Center (iOS) #

Game Center sign-in is silent when the player is already signed into Game Center at the OS level. If they aren’t, the plugin presents the system sign-in sheet automatically. If the player cancels or Game Center is disabled, Authenticate() throws a GameKitException.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
using Apple.GameKit;
using Apple.GameKit.Players;

private async Task<ISession> AuthenticateGameCenterAsync(IClient client)
{
    if (!GKLocalPlayer.Local.IsAuthenticated)
    {
        // Silent when the OS-level Game Center session exists.
        // Throws GameKitException on cancel or failure.
        _ = await GKLocalPlayer.Authenticate();
    }

    var response = await GKLocalPlayer.Local.FetchItemsForIdentityVerificationSignature();

    // Nakama client call: exchanges the Game Center signature for a Nakama session.
    return await client.AuthenticateGameCenterAsync(
        Application.identifier,
        GKLocalPlayer.Local.TeamPlayerId,
        response.PublicKeyUrl,
        Convert.ToBase64String(response.GetSalt()),
        Convert.ToBase64String(response.GetSignature()),
        response.Timestamp.ToString());
}

Pass TeamPlayerId as the player ID. GamePlayerId is only valid for Apple Arcade titles, and the legacy playerID isn’t exposed by the plugin. Pass response.Timestamp through unchanged: the raw value is part of the signed payload, so converting between seconds and milliseconds breaks signature verification.

Google Play Games (Android) #

Play Games Services v2 attempts sign-in automatically at launch. Authenticate() reports the result of that attempt. For Nakama you exchange a single-use server auth code, requested after sign-in succeeds:

Step 2

Fall back to device authentication #

Silent sign-in can fail if the player has no Game Center or Play Games profile, declines the sign-in prompt, is offline, or hits a provider outage. When that happens, the flow falls through to device ID authentication: the player still reaches the game, but on a Nakama session backed only by the device ID, with no social ID linked to the account yet.

Don’t use an OS-level hardware identifier (such as SystemInfo.deviceUniqueIdentifier in Unity or OS.get_unique_id() in Godot) as the device ID. Instead, generate a secure random UUID on first launch and store it in your app’s private local storage; see Store the device ID securely .
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public async Task<ISession> SignInAsync(IClient client)
{
    // Generate the device ID as a random GUID. Store it securely so it survives
    // reinstall; see "Store the device ID securely".
    var deviceId = Guid.NewGuid().ToString();
    ISession session = null;

#if UNITY_IOS
    try
    {
        // AuthenticateGameCenterAsync is the Game Center (iOS) code example in Step 1.
        session = await AuthenticateGameCenterAsync(client);
    }
    catch (GameKitException)
    {
        // Player cancelled or Game Center is disabled. Fall through.
    }
#elif UNITY_ANDROID
    try
    {
        // AuthenticatePlayGamesAsync is the Google Play Games (Android) code example in Step 1.
        session = await AuthenticatePlayGamesAsync(client);
    }
    catch (ApiResponseException)
    {
        // Server-side exchange failed (config, network). Fall through.
    }
#endif

    if (session == null)
    {
        // Fallback: least friction, weakest durability.
        session = await client.AuthenticateDeviceAsync(deviceId);
    }
    else
    {
        // Silent sign-in succeeded: attach this device to the account.
        await LinkDeviceIdAsync(client, session, deviceId);
    }

    PlayerPrefs.SetString(AuthTokenKey, session.AuthToken);
    PlayerPrefs.SetString(RefreshTokenKey, session.RefreshToken);
    return session;
}
Step 3

When silent sign-in succeeds, immediately link the stored device ID to the Nakama account. This is what makes the fallback safe: on the next launch, even if silent sign-in fails, AuthenticateDeviceAsync with the same ID resolves to the same account rather than creating a new one.

Linking the same device ID to the same account again is a no-op, so it’s safe to call on every successful silent sign-in.

1
2
3
4
private async Task LinkDeviceIdAsync(IClient client, ISession session, string deviceId)
{
    await client.LinkDeviceAsync(session, deviceId);
}
Step 4

When silent sign-in fails the first time, a player lands in the game through the device authentication fallback. On later launches that Nakama session restores from cache, and you can run the silent sign-in flow again to link a social ID once one becomes available.

Why does retrying on a later launch matter? A player who missed Game Center on their first launch gets a device account and stays device-only until the session expires, up to several days depending on your Nakama settings. That’s long enough to build progress on an account tied to no social ID.

Flowchart of the later-launch flow: restore the cached session, then retry the silent social sign-in and link so a device-only account upgrades to a social one.
Later launch: restore the cached session, then retry the social sign-in and link to upgrade a device-only account.

A player who is already signed in at the OS level is authenticated silently, with no prompt. For a player who isn’t, whether a sheet appears is up to the platform, not your game, and each platform stops offering it once the player has declined. What you can do about that differs by platform:

  • Android: offer a manual sign-in button that calls ManuallyAuthenticate. This explicitly requests sign-in, unlike Authenticate, which only reports the result of the automatic attempt made at launch.
  • iOS: Game Center has no equivalent API to force the prompt, and Apple advises against adding your own sign-in toggle. Authenticate the player once at launch; a player who has declined is reported as not authenticated and sees no dialog, and re-enables Game Center from the system Settings.

Here’s the sample code for this flow. If the social ID already belongs to another Nakama account, LinkGameCenterAsync or LinkGoogleAsync will throw an exception with error code 409. You’ll handle conflict resolution in the next section.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
public async Task<ISession> RestoreOrSignInAsync(IClient client)
{
    // Restore the cached session, keeping an hour's buffer so one about to expire counts as expired.
    var session = Session.Restore(
        PlayerPrefs.GetString(AuthTokenKey),
        PlayerPrefs.GetString(RefreshTokenKey));

    // First launch, or the refresh token expired: run the full silent sign-in flow.
    if (session == null || session.HasRefreshExpired(DateTime.UtcNow.AddHours(1)))
    {
        return await SignInAsync(client);
    }

    // Valid cached session: the player is back in the same account without re-authenticating.
    // Still attempt the social link every launch, so a device-only account upgrades as soon as
    // the player is signed in at the OS level.
    return await SignInAndLinkSocialAsync(client, session, GetOrCreateDeviceId());
}

// Sign in at the OS level, then link that platform social ID to the current session. A 409 means
// the ID already belongs to another Nakama account, so it hands off to ResolveConflictAsync
// (see "Resolve account conflicts").
private async Task<ISession> SignInAndLinkSocialAsync(IClient client, ISession session, string deviceId)
{
#if UNITY_IOS
    try
    {
        // On a restored session the player is usually already signed in, so skip the sheet.
        if (!GKLocalPlayer.Local.IsAuthenticated)
        {
            await GKLocalPlayer.Authenticate();
        }

        var sig = await GKLocalPlayer.Local.FetchItemsForIdentityVerificationSignature();
        await client.LinkGameCenterAsync(
            session,
            Application.identifier,
            GKLocalPlayer.Local.TeamPlayerId,
            sig.PublicKeyUrl,
            Convert.ToBase64String(sig.GetSalt()),
            Convert.ToBase64String(sig.GetSignature()),
            sig.Timestamp.ToString());
        return session;
    }
    catch (GameKitException)
    {
        // Declined, disabled, or offline: keep the restored session and try again next launch.
        return session;
    }
    catch (ApiResponseException ex) when (ex.StatusCode == 409)
    {
        return await ResolveConflictAsync(client, session, deviceId);
    }
#elif UNITY_ANDROID
    var tcs = new TaskCompletionSource<string>();
    PlayGamesPlatform.Instance.Authenticate(status =>
    {
        if (status != SignInStatus.Success) { tcs.SetResult(null); return; }
        PlayGamesPlatform.Instance.RequestServerSideAccess(forceRefreshToken: false, tcs.SetResult);
    });

    try
    {
        await client.LinkGoogleAsync(session, await tcs.Task);
        return session;
    }
    catch (ApiResponseException ex) when (ex.StatusCode == 409)
    {
        return await ResolveConflictAsync(client, session, deviceId);
    }
#endif
}

// Placeholder: the social ID already belongs to another Nakama account.
// ResolveConflictAsync lets the player choose which to keep and rewires the links to match.
// It is covered in the next section, "Resolve account conflicts".
private Task<ISession> ResolveConflictAsync(IClient client, ISession session, string deviceId)
{
    throw new NotImplementedException(); // See "Resolve account conflicts".
}

Resolve account conflicts #

Now you’ll fill in ResolveConflictAsync, the placeholder the previous step handed off to when the link returned a 409. That status code means the ID you tried to link already belongs to another Nakama account, so the player now has two saves. The other account is untouched: Nakama never auto-unlinks an ID and moves it.

This guide hits the conflict through LinkGameCenterAsync and LinkDeviceAsync, but every link method behaves the same way. LinkAppleAsync, LinkEmailAsync, LinkFacebookAsync, LinkGoogleAsync,and LinkSteamAsync all return a 409 when the identifier you pass already belongs to another account.

Whichever link call caused the conflict, the general strategy is the same:

  • Identify the other account: authenticate the conflicting ID with create: false to fetch the account that already owns it, without creating a new one.
  • Let the player choose which account to keep: surface a progress choice UI comparing the accounts on any metric meaningful to your game.
Progress choice UI showing two accounts, a device account and a Game Center account, with their level, coins, trophies, and heroes compared side by side
Progress choice UI
  • Rewire the IDs to match the choice. unlink the ID from the discarded account, then link it to the account the player chooses. Once the IDs are moved, you can delete the discarded account to avoid piling up orphaned accounts.
  • Replace the session everywhere you hold one, as covered in After resolving: replace the session .
Flowchart of resolving a social-ID link 409: fetch the conflicting account, show the progress choice UI, then rewire the IDs to the account the player keeps by relinking and orphaning or deleting the other account.
Steps to resolve account conflicts with an example of a social ID conflict

The code examples below implement this flow for a conflict when linking a Game Center or Google Play Games ID. Here’s what the Game Center example demonstrates:

  1. Fetch the conflicting account. Authenticate the Game Center ID with create: false so Nakama returns the account that already owns it, rather than creating a new one.
  2. Show the progress choice UI to compare the two accounts and let the player pick which to keep.
  3. Rewire the IDs to the chosen account:
    • If the player keeps the other account, move the device ID onto it (unlink from the current session, link to the other), then delete the current account. Link before delete, so a failure before the delete leaves the current session intact.
    • If the player keeps the current account, move the Game Center ID onto it. Fetch both Game Center signatures before the unlink so the window where the ID belongs to no account is a single call wide, since each signature is single use.
  4. Delete the discarded account once the IDs are moved.

One caveat on the keep-other branch: if the device ID is the current account’s only identifier, UnlinkDeviceAsync returns a 403 because Nakama won’t remove an account’s last ID. Either delete the current account first to free the ID (this branch deletes it anyway), or park a placeholder first as described in Unlinking an account’s last identifier .

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
private async Task<ISession> ResolveConflictAsync(IClient client, ISession currentSession, string deviceId)
{
    // 1. Fetch the account that already owns the Game Center ID (create: false).
    var conflictSig = await GKLocalPlayer.Local.FetchItemsForIdentityVerificationSignature();
    var otherSession = await client.AuthenticateGameCenterAsync(
        Application.identifier,
        GKLocalPlayer.Local.TeamPlayerId,
        conflictSig.PublicKeyUrl,
        Convert.ToBase64String(conflictSig.GetSalt()),
        Convert.ToBase64String(conflictSig.GetSignature()),
        conflictSig.Timestamp.ToString(),
        username: null, create: false);

    // 2. Let the player choose which account to keep.
    var currentAccount = await client.GetAccountAsync(currentSession);
    var otherAccount = await client.GetAccountAsync(otherSession);
    var choice = await ShowProgressChoiceAsync(currentAccount, otherAccount);

    if (choice == ProgressChoice.KeepOtherAccount)
    {
        // 3a. Keep the other account: move the device ID onto it, then delete the current account.
        await client.UnlinkDeviceAsync(currentSession, deviceId);
        await client.LinkDeviceAsync(otherSession, deviceId);
        await client.DeleteAccountAsync(currentSession);
        return otherSession;
    }

    // 3b. Keep the current account: move the Game Center ID onto it, then delete the other account.
    var unlinkSig = await GKLocalPlayer.Local.FetchItemsForIdentityVerificationSignature();
    var relinkSig = await GKLocalPlayer.Local.FetchItemsForIdentityVerificationSignature();
    await client.UnlinkGameCenterAsync(
        otherSession,
        Application.identifier,
        GKLocalPlayer.Local.TeamPlayerId,
        unlinkSig.PublicKeyUrl,
        Convert.ToBase64String(unlinkSig.GetSalt()),
        Convert.ToBase64String(unlinkSig.GetSignature()),
        unlinkSig.Timestamp.ToString());
    await client.LinkGameCenterAsync(
        currentSession,
        Application.identifier,
        GKLocalPlayer.Local.TeamPlayerId,
        relinkSig.PublicKeyUrl,
        Convert.ToBase64String(relinkSig.GetSalt()),
        Convert.ToBase64String(relinkSig.GetSignature()),
        relinkSig.Timestamp.ToString());
    await client.DeleteAccountAsync(otherSession);
    return currentSession;
}

Unlinking an account’s last identifier #

If the ID you’re unlinking, a device ID for example, is that account’s only identifier (no other form of ID is linked to the account), the Unlink*Async call throws an exception with code 403. Nakama won’t remove an account’s last ID. This applies to every Unlink*Async method, like UnlinkGameCenterAsync, UnlinkGoogleAsync etc. There’re two ways to handle it:

  • Delete first. When you’re going to delete the unchosen account anyway, delete it before the link. The delete frees the ID in one call, so you never call unlink on a last identifier.
  • Park a placeholder first. When you’d rather orphan the account than delete it, link a placeholder ID such as a device ID (Guid.NewGuid().ToString()) to it first, so the ID you’re unlinking is no longer its last identifier, then unlink and relink.

Resolve a device ID conflict #

A device ID conflict can happen in a number of scenarios. One of them, in the context of the silent sign-in flow this guide proposes, surfaces over a specific sequence of launches:

First game launch:

  • Silent social sign-in fails → authenticate with device ID → player makes progress on a device-only account.

Later game launch (no cached session):

  • Cached session has expired → silent sign-in succeeds and authenticates with the social ID, creating a fresh account → app links the stored device ID to that account → conflict: error 409, because the device ID is already linked to another account (the original device-only account) → resolution: keep the device ID account’s progress by re-authenticating with the device ID and linking the social ID onto it.

This only arises when a cached session isn’t available, for example when the session expired. If the session is restored, the player is already back in their original account and the link is a no-op, as covered in Link the social ID on later launches .

Unlike the social ID case, you don’t usually need the progress choice UI here. The device-only account holds the player’s progress, while the social account was just created and is empty. Keep the device account and move the social ID onto it:

  1. Re-authenticate with the device ID using AuthenticateDeviceAsync(deviceId) to get back the account that holds the progress.
  2. Free the social ID from the fresh account so you can move it. The fresh account’s only identifier is that social ID, so you hit the last-identifier rule from Unlinking an account’s last identifier : park a placeholder device ID on the fresh account first, then the social ID is no longer its last identifier and UnlinkGameCenterAsync or UnlinkGoogleAsync succeeds.
  3. Link the freed social ID onto the device account, then continue on the device account’s session.

The fresh account now has only the placeholder device ID linked to it. Since silent sign-in created it this launch and it holds no progress, you can delete it here, or leave it orphaned for a server-side cleanup job.

If the social ID resolved to a pre-existing account, it may hold its own progress, so fall back to the progress choice UI from Resolve a social ID conflict and let the player decide.

After resolving: replace the session #

Once a conflict is resolved, two things need to be updated:

  • The live in-memory session, the session your code sends with every request. Update it, or the rest of this run keeps calling the server with the discarded account’s token. If that account was deleted, every call fails with Auth token invalid.
  • The persisted tokens in durable storage. Overwrite them so the next relaunch reads the resolved account’s tokens back through Session.Restore and the player returns to the account they kept.

The mechanism differs between plain Nakama and Hiro, but you do both in both cases.

Plain Nakama: swap the session reference your code holds, then persist the new tokens for the next relaunch.

1
2
3
4
5
6
7
8
// resolvedSession is what ResolveConflictAsync returned.

// 1. Update the live session your code passes to client calls.
_session = resolvedSession;

// 2. Persist for the next relaunch: Session.Restore reads these on the next launch.
PlayerPrefs.SetString(AuthTokenKey, resolvedSession.AuthToken);
PlayerPrefs.SetString(RefreshTokenKey, resolvedSession.RefreshToken);

Hiro: NakamaSystem holds the session internally, update it in place with Update, then persist for the next relaunch.

1
2
3
4
5
6
// 1. Update the live NakamaSystem session in place.
(nakamaSystem.Session as Session).Update(resolvedSession.AuthToken, resolvedSession.RefreshToken);

// 2. Persist for the next relaunch.
PlayerPrefs.SetString(AuthTokenKey, resolvedSession.AuthToken);
PlayerPrefs.SetString(RefreshTokenKey, resolvedSession.RefreshToken);

Store the device ID securely #

On mobile, you can’t count on a platform-provided device identifier staying the same. On iOS, SystemInfo.deviceUniqueIdentifier maps to identifierForVendor, which resets the moment the player uninstalls your app and no other apps from your team remain on the device. Android identifiers shift too, across reinstalls and factory resets. Rely on one of these and a player who reinstalls comes back as a brand-new account, with their fallback progress stranded.

Generate your own device ID once, as a random GUID, and store it somewhere that survives a reinstall. Treat it like a password because in Nakama the device ID is a bearer credential, so anyone who has it can call AuthenticateDeviceAsync and become that account.

For storage that survives a reinstall, use the iOS Keychain and Android Block Store. The details, and the fallbacks for when they aren’t available, are below.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private const string DeviceIdKey = "nakama.deviceId";

public string GetOrCreateDeviceId()
{
    // 1. Durable store first: iOS Keychain / Android Block Store.
    // SecureStore here stands in for your keychain or Block Store wrapper.
    var deviceId = SecureStore.Load(DeviceIdKey);

    // 2. Migrate an ID that older builds kept in PlayerPrefs.
    if (string.IsNullOrEmpty(deviceId))
    {
        deviceId = PlayerPrefs.GetString(DeviceIdKey);
    }

    // 3. First run: generate. A GUID meets Nakama's 10-128 byte requirement.
    if (string.IsNullOrEmpty(deviceId))
    {
        deviceId = Guid.NewGuid().ToString();
    }

    SecureStore.Save(DeviceIdKey, deviceId);
    PlayerPrefs.SetString(DeviceIdKey, deviceId);
    return deviceId;
}

Troubleshooting #

Here are some of the common errors and gotchas you’ll run into with this flow, along with what causes them and how to fix them.

SymptomCauseFix
404 ApiResponseException on Authenticate*Asynccreate: false and no Nakama account owns this ID.Expected behavior; create the account or handle as “player not found”. Inside a 409 recovery flow it means the conflicting account vanished: retry the link.
409 on LinkGoogleAsync / LinkGameCenterAsync (social ID)The social ID already belongs to another Nakama account.Run the conflict recovery flow in Resolve account conflicts . All link conflicts are 409; Nakama never returns 412.
409 on LinkDeviceAsync (device ID)The stored device ID already belongs to another Nakama account, usually a fallback account created on this device during an earlier launch when silent sign-in was unavailable.Run the reverse of the social-ID recovery. See Resolve a device ID conflict for the full flow.
Error on Unlink*Async removing an account’s last identifierNakama refuses to remove an account’s last identifier, which would leave the account unreachable. This surfaces as a 403 on current server versions; older versions may return a 400.Link another ID first, such as a placeholder device ID (see the placeholder pattern in Resolve account conflicts ).
401 Could not authenticate Google profile.Missing or wrong credentials_json (must be the web OAuth client JSON), or an expired/reused auth code.Fix the server config; request a fresh code per call. Root cause visible at debug log level.
Server exits at startup: Failed to parse Google's credentials JSONEither a service account key was configured instead of the web OAuth client JSON, or the correct web client JSON is missing redirect_uris. The underlying error distinguishes them: oauth2/google: no credentials found means the wrong file type; oauth2/google: missing redirect URL in the client_credentials.json means the right file type but an empty or absent redirect_uris array. The second case is common and expected: the web client used as the Play Games server client ID has no redirect URIs registered in Google Cloud Console, since it’s never used in a browser redirect, so its downloaded JSON legitimately has none.For the first case, download the web application client JSON from Google Cloud Console credentials instead of a service account key. For the second, add a placeholder redirect_uris entry to the JSON, for example "redirect_uris": ["urn:ietf:wg:oauth:2.0:oob"]; Nakama’s OAuth loader requires the field to be present to parse the JSON but doesn’t otherwise validate it against your app’s real redirect flow.
401 Could not authenticate GameCenter profile.Wrong player ID (use TeamPlayerId), modified timestamp, or bundle ID mismatch.Pass the FetchItems values through unchanged; verify the bundle ID.
Play Games sign-in fails on release builds onlySHA-1 mismatch: store builds are signed with the Play App Signing key.Register the Play App Signing SHA-1 as an Android OAuth client alongside the debug one.