Aug 19, 2026 · 6 min read

OAuth 2.0 and PKCE for a Unity game client

A complete authorization-code-with-PKCE login for a Unity game: the deep-link handling desktop and mobile each need, and what the server must enforce.

Our Unity MMO signs players in with their existing web account. No password is typed into the game, the game never sees credentials, and the same account owns their inventory and currency on the website.

The mechanism is the authorization code flow with PKCE. This is how both halves are built, and the parts of it that are specific to a game client rather than a web app.

Why PKCE, and why no client secret

A game binary is a public client. Anything compiled into it, in any form, is readable by anyone who downloads it. A client secret shipped in a game is not a secret, and no amount of obfuscation changes that.

PKCE replaces the secret with a value the client invents fresh on every login:

  1. The client generates a random code_verifier.
  2. It sends only SHA256(verifier), base64url encoded, as the code_challenge, when it opens the login page.
  3. When it later exchanges the authorization code for tokens, it sends the original verifier.
  4. The server hashes the verifier and checks it against the challenge it stored.

An attacker who intercepts the authorization code cannot use it, because they do not have the verifier that goes with it. Nothing durable needs to be secret, so nothing durable can be extracted from the binary.

Use S256. The plain method exists for constrained devices and is worth nothing here. Our server rejects anything other than S256 outright.

The flow end to end

Game            System browser              Your server
 |  build verifier + challenge                    |
 |  open /oauth/authorize?...&code_challenge=..   |
 |------------------------------------------->    |
 |                        user logs in, consents  |
 |         redirect to mygame://auth/callback?code=..&state=..
 |  <-----------------------------------------    |
 |  POST /oauth/token  { code, code_verifier }    |
 |------------------------------------------->    |
 |  <-- access_token, refresh_token, user         |

The login happens in the system browser, never in a webview inside the game. A webview you control can read what the user types, which defeats the point of not handling their password, and identity providers increasingly refuse to authenticate inside one.

Unity: starting the login

public void StartLogin()
{
    _pendingState  = RandomUrlSafe(48);
    _codeVerifier  = RandomUrlSafe(64);
    PersistPendingAuthState();

    using var sha = SHA256.Create();
    var challenge = ToBase64Url(sha.ComputeHash(Encoding.UTF8.GetBytes(_codeVerifier)));

    var url =
        $"{baseUrl}/oauth/authorize" +
        $"?response_type=code" +
        $"&client_id={Uri.EscapeDataString(clientId)}" +
        $"&redirect_uri={Uri.EscapeDataString(redirectUri)}" +
        $"&scope={Uri.EscapeDataString(scope)}" +
        $"&state={Uri.EscapeDataString(_pendingState)}" +
        $"&code_challenge={Uri.EscapeDataString(challenge)}" +
        $"&code_challenge_method=S256";

    Application.OpenURL(url);
}
private static string RandomUrlSafe(int byteCount)
{
    var bytes = new byte[byteCount];
    using var rng = RandomNumberGenerator.Create();
    rng.GetBytes(bytes);
    return ToBase64Url(bytes);
}

private static string ToBase64Url(byte[] bytes)
    => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');

Use RandomNumberGenerator, not UnityEngine.Random and not System.Random. Neither of those is a cryptographic source, and this is the one value the whole scheme rests on.

The spec allows a verifier of 43 to 128 characters. Sixty-four random bytes, base64url encoded, lands comfortably inside that.

Getting the browser back into your game

This is the part that is genuinely different from a web app, and where most of the work is.

The redirect target is a custom URI scheme registered by your game, for example mygame://auth/callback. On mobile you declare it in the Android manifest or iOS URL types. On desktop it is a .desktop file with a MimeType entry on Linux, or a registry entry on Windows.

Unity then hands it to you in two completely different ways.

Mobile uses Application.deepLinkActivated. Subscribe in OnEnable, and also check Application.absoluteURL immediately, because a cold start (the app was not running when the link was opened) fires the event before your script exists:

private void OnEnable()
{
    Application.deepLinkActivated += HandleDeepLink;
    if (!string.IsNullOrEmpty(Application.absoluteURL))
        HandleDeepLink(Application.absoluteURL);
}

Desktop does not use that event at all. The OS launches your executable with the URI as a command-line argument:

private void CheckCommandLineArgs()
{
    foreach (var arg in Environment.GetCommandLineArgs())
    {
        if (arg.StartsWith("mygame://", StringComparison.OrdinalIgnoreCase))
        {
            HandleDeepLink(arg);
            return;
        }
    }
}

Which leads to the least obvious decision in our implementation: on desktop, the game quits shortly after opening the browser. The URI handler starts a fresh copy of the game with the callback URI as an argument, and one process that owns the login is much simpler than a running instance trying to receive a URI the OS is delivering to a new one.

That is a design choice, not a rule. The alternative is a loopback redirect (http://127.0.0.1:<port>/callback) with a tiny local HTTP listener in the game, which keeps the process alive and is what the current native-app guidance prefers. It costs you a listening socket and some firewall-prompt awkwardness on Windows. Both are legitimate; pick knowing the trade.

Persist the verifier before you open the browser

Because the process may not survive the round trip, the pending state and code_verifier have to outlive it. We write both to PlayerPrefs before calling Application.OpenURL, and load them back when a deep link arrives.

Forget this and the flow works perfectly in the editor, where the game never quits, and fails on every desktop build.

Check state, then destroy it

if (state != _pendingState)
{
    OnLoginFailed?.Invoke("state_mismatch");
    return;
}

// Clear pending state immediately so a replayed link is rejected.
var verifier = _codeVerifier;
_pendingState = null;
ClearPendingAuthState();

StartCoroutine(ExchangeCodeCoroutine(code, verifier));

state is CSRF protection: it proves the callback belongs to a login this install started. Clearing it before the exchange means the same callback URI replayed a second time finds nothing to match and is rejected, which matters because on desktop that URI has been handled by the operating system and may sit in a shell history or a recent-items list.

What the server has to enforce

The client-side ceremony is worthless if the server is lax. Ours enforces all of this, and if you are implementing the other end, this is the list:

  • Authorization codes are single use. The row is selected lockForUpdate() inside a transaction and consumed_at is stamped before anything is issued, so two concurrent exchanges of the same code cannot both mint tokens.
  • Codes expire fast. Five minutes. They only have to survive a login.
  • redirect_uri must match exactly, both against the value registered for that client and against the value used when the code was issued. Exact string comparison, never a prefix match.
  • code_challenge_method must be S256. Nothing else is accepted.
  • Verify with a constant-time comparison. hash_equals in PHP, its equivalent elsewhere.
  • Store hashes, not tokens. Authorization codes and refresh tokens live in the database as SHA-256 hashes. A database dump then does not hand anybody a working session.
  • Re-check the user at exchange time. A banned or deleted account must not be able to complete a login that started before the ban.
if (! hash_equals($authCode->code_challenge, $this->pkceChallenge($verifier))) {
    return response()->json(['error' => 'invalid_grant'], 422);
}

Refresh tokens, rotated

Access tokens last an hour, refresh tokens thirty days. Every refresh rotates: the old refresh token is revoked, a new one is issued, the previous access token is deleted so a leaked one cannot outlive its rotation, and the old row records which token replaced it.

That replacement chain is the useful part. If an old refresh token that has already been rotated is ever presented, that is evidence a token leaked, and you have a record to act on.

The client should refresh proactively. Ours refreshes on startup if the access token is within five minutes of expiry, and again before any authenticated request that would otherwise go out with an expired token. Reacting to a 401 in the middle of gameplay is a worse experience than refreshing early.

Where do you store the tokens?

Honestly: we use PlayerPrefs, which is plaintext on disk, and that is a compromise rather than a recommendation.

The mitigations that make it acceptable are scope and expiry. The access token lives an hour and carries narrow scopes (profile:read, inventory:read, and similar), and both it and the refresh token can be revoked server-side the moment anything looks wrong. Anyone who can read PlayerPrefs already has code execution on that machine, at which point they can also read your process memory.

If you need better, the platform keystores (Keychain, Android Keystore, libsecret) are the answer, at the cost of a native plugin per platform. Decide based on what the token can actually do. A token that can spend real money deserves more than one that can read a profile.

The checklist

  • Public client, no secret, PKCE with S256.
  • System browser, not an in-game webview.
  • Cryptographic RNG for state and code_verifier.
  • Persist the verifier before you open the browser.
  • Handle deep links twice: command-line args on desktop, deepLinkActivated plus absoluteURL on mobile.
  • Validate state, then clear it.
  • Server: single-use codes, short expiry, exact redirect match, constant-time compare, hashed storage.
  • Rotate refresh tokens and revoke the old access token with them.
  • Request the narrowest scopes the game actually needs.

Fopull LLC is a software studio in Knoxville, TN. We built this flow for our own Unity MMO against our own OAuth server, and we build game and backend systems for other people. Tell us what you need.

Written by Ty Johnston at Fopull LLC, a software studio in Knoxville, TN. We build custom software and ship our own — Floptle, Storage Sifter, and more.

Read next