Introducing the next era of Duende IdentityServer.

Read our CEO’s announcement

Authenticating Players in Godot 4 with OAuth 2.0 and OpenID Connect

Khalid Abuhakmeh
Two blue circles

Games need player identity. Leaderboards, cloud saves, multiplayer matchmaking, and anti-cheat all depend on knowing who’s playing. Godot 4 with C# gives you access to the full .NET ecosystem, including the same authentication libraries used in web and mobile apps.

This tutorial covers two OAuth 2.0 flows using Duende IdentityServer as the identity provider: Authorization Code with PKCE for desktop games, and the Device Authorization Grant for consoles and limited-input devices.

📦 Full source: GitHub repository to clone and run if you want the fast path.

Which Flow Should You Use?

Flow Best For How It Works

Authorization Code + PKCE

Desktop (Windows/Mac/Linux)

Opens the system browser, captures the callback via a localhost listener

Device Authorization Grant

Consoles, TVs, VR headsets, arcade cabinets

Displays a code and URL; player authorizes on a second device (phone, laptop)

We’ll implement both.

Architecture

flowchart TB
    subgraph GodotGame["Godot Game"]
        MainMenu["MainMenu"]
        
        subgraph BrowserFlow["Browser Auth Flow"]
            BrowserAuth["BrowserAuth"]
            ShellOpen["OS.ShellOpen(authorizeUrl)"]
            HttpListener["HttpListener :8948"]
        end
        
        subgraph DeviceFlow["Device Auth Flow"]
            DeviceAuth["DeviceAuth"]
            DisplayCode["displays user_code + URL"]
            Poll["polls token endpoint"]
        end
        
        Authenticated["Authenticated\n(claims display)"]
        
        MainMenu --> BrowserAuth
        MainMenu --> DeviceAuth
        
        BrowserAuth --> ShellOpen
        ShellOpen -.->|"callback"| HttpListener
        HttpListener --> Authenticated
        
        DeviceAuth --> DisplayCode
        DisplayCode --> Poll
        Poll --> Authenticated
    end
    
    subgraph IDP["Duende IdentityServer\nhttps://localhost:5001"]
        Clients["Clients:\n• godot-browser-client\n• godot-device-client"]
        Users["Users: alice / bob"]
    end
    
    GodotGame <-->|"HTTPS"| IDP

Prerequisites

Requirement Version Notes

.NET SDK

9.0+ (10.0 for the IdentityServer project)

Required for both projects

Godot Engine

4.4+ with .NET/C# support

Must be the .NET build because the standard GDScript-only build won’t work

Duende IdentityServer Templates

Latest

dotnet new install Duende.IdentityServer.Templates

A web browser

Any

For the login UI

NuGet Packages

Godot game project:

Package Version Purpose

Duende.IdentityModel

8.x

OAuth/OIDC client helpers, such as discovery, token requests, and PKCE utilities

QRCoder

1.x

QR code generation for the device flow

IdentityServer project:

Package Version Purpose

Duende.IdentityServer

8.x

The identity provider

⚠️ HTTPS Certificate: Before you start, run:

dotnet dev-certs https --trust

If you skip this step, all token requests will fail with TLS certificate errors, and the error messages won’t always point you to the root cause.

Step 1: Set Up the Identity Provider

We need a running IdentityServer that knows about our game clients. The Duende templates scaffold a working server. We only need to configure the clients.

Create a new project from the empty template:

Shell

dotnet new isempty -n IdentityServer -o src/IdentityServer

Open Config.cs and replace its contents with two client definitions and test users:

Csharp

using Duende.IdentityServer.Models;
using Duende.IdentityServer.Test;
using System.Security.Claims;

namespace IdentityServer;

public static class Config
{
    public static IEnumerable<IdentityResource> IdentityResources =>
    [
        new IdentityResources.OpenId(),
        new IdentityResources.Profile(),
        new IdentityResources.Email()
    ];

    public static IEnumerable<ApiScope> ApiScopes => Array.Empty<ApiScope>();

    public static IEnumerable<Client> Clients =>
    [
        // Authorization Code + PKCE (system browser flow)
        new Client
        {
            ClientId = "godot-browser-client",
            ClientName = "Godot Browser Auth",
            AllowedGrantTypes = GrantTypes.Code,
            RequireClientSecret = false,   // Public client — no secret in the game binary
            RequirePkce = true,
            RedirectUris = { "http://localhost:8948/callback" },
            PostLogoutRedirectUris = { "http://localhost:8948/callback" },
            AllowedScopes = { "openid", "profile", "email" },
            AllowOfflineAccess = false
        },

        // Device Authorization Grant (console/TV flow)
        new Client
        {
            ClientId = "godot-device-client",
            ClientName = "Godot Device Auth",
            AllowedGrantTypes = GrantTypes.DeviceFlow,
            RequireClientSecret = false,   // Public client
            AllowedScopes = { "openid", "profile", "email" },
            AllowOfflineAccess = false
        }
    ];

    public static List<TestUser> TestUsers =>
    [
        new TestUser
        {
            SubjectId = "1",
            Username = "alice",
            Password = "alice",
            Claims =
            [
                new Claim("name", "Alice Smith"),
                new Claim("email", "alice@example.com"),
                new Claim("email_verified", "true")
            ]
        },
        new TestUser
        {
            SubjectId = "2",
            Username = "bob",
            Password = "bob",
            Claims =
            [
                new Claim("name", "Bob Jones"),
                new Claim("email", "bob@example.com"),
                new Claim("email_verified", "true")
            ]
        }
    ];
}

A few things to note about the client configuration:

  • RequireClientSecret = false
    Both clients are public. Native/desktop apps can’t keep secrets, so we rely on PKCE for the browser flow and the device code’s short lifespan for the device flow.
  • RedirectUris = { "http://localhost:8948/callback" }
    The game will spin up a temporary HTTP listener on this port to capture the browser callback.
  • RequirePkce = true
    Proof Key for Code Exchange prevents authorization code interception attacks.

The Program.cs registers these configurations and wires up the middleware:

Csharp

using IdentityServer;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddIdentityServer()
    .AddInMemoryIdentityResources(Config.IdentityResources)
    .AddInMemoryApiScopes(Config.ApiScopes)
    .AddInMemoryClients(Config.Clients)
    .AddTestUsers(Config.TestUsers);

builder.Services.AddRazorPages();

var app = builder.Build();

app.UseStaticFiles();
app.UseRouting();
app.UseIdentityServer();
app.UseAuthorization();
app.MapRazorPages();

app.Run();

Start the server:

Shell

dotnet run --project src/IdentityServer

The template includes login, consent, and device verification pages by default. Our screenshots show a customized theme; the default template UI behaves the same way.

Godot game engine login OpenID Connect IdentityServer

Step 2: Create the Godot Project

Open Godot (the .NET/C# build) and create a new project in src/GodotGame. Add the NuGet packages to the generated .csproj:

Xml

<ItemGroup>
    <PackageReference Include="IdentityModel" Version="7.*" />
    <PackageReference Include="QRCoder" Version="1.*" />
</ItemGroup>

Build once to restore packages (Project → Build, or dotnet build from terminal).

GodotGame/
├── Scenes/
│   ├── MainMenu.tscn        ← Two buttons: Browser Auth / Device Auth
│   ├── BrowserAuth.tscn     ← Status label, start button, back button
│   ├── DeviceAuth.tscn      ← QR code, user code label, URL label, back button
│   └── Authenticated.tscn   ← Greeting, claims labels, logout button
├── Scripts/
│   ├── MainMenu.cs          ← Scene navigation
│   ├── BrowserAuthFlow.cs   ← PKCE flow logic
│   ├── DeviceAuthFlow.cs    ← Device flow logic
│   └── AuthenticatedScene.cs ← Claims display
└── Services/
    ├── OAuthService.cs       ← Core auth logic (shared by both flows)
    ├── TokenStorage.cs       ← In-memory token store
    └── QrCodeService.cs      ← QR code generation

Step 3: Build the Shared OAuth Service

We’ll centralize the shared logic in a static OAuthService class.

3a. Configuration and Discovery

Csharp

using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Duende.IdentityModel;
using Duende.IdentityModel.Client;

namespace GodotGame.Services;

public static class OAuthService
{
    private const string Authority = "https://localhost:5001";
    private const string BrowserClientId = "godot-browser-client";
    private const string DeviceClientId = "godot-device-client";
    private const string RedirectUri = "http://localhost:8948/callback";
    private const string Scopes = "openid profile email";

    private static DiscoveryDocumentResponse? _discoveryCache;

    public static async Task<DiscoveryDocumentResponse?> GetDiscoveryDocumentAsync()
    {
        if (_discoveryCache is { IsError: false })
            return _discoveryCache;

        using var client = new HttpClient();
        var disco = await client.GetDiscoveryDocumentAsync(Authority);

        if (disco.IsError)
        {
            Console.Error.WriteLine($"[OAuthService] Discovery error: {disco.Error}");
            return null;
        }

        _discoveryCache = disco;
        return disco;
    }
}

The discovery document (fetched from /.well-known/openid-configuration) tells us where the authorization, token, and userinfo endpoints live. We cache it so that subsequent calls avoid a network round-trip.

3b. PKCE Generation

Csharp

public static (string codeVerifier, string codeChallenge) GeneratePkce()
{
    var verifier = CryptoRandom.CreateUniqueId(32);
    var challengeBytes = SHA256.HashData(Encoding.ASCII.GetBytes(verifier));
    var challenge = Base64Url.Encode(challengeBytes);
    return (verifier, challenge);
}

PKCE (Proof Key for Code Exchange) prevents the interception of authorization codes. The flow works like this: the game generates a random verifier, hashes it into a challenge, and sends only the challenge to IdentityServer. When exchanging the authorization code for tokens, the game sends the original verifier. IdentityServer hashes it and confirms that it matches the earlier challenge, thus proving that the token requester is the same party that started the login.

3c. Token Storage

Csharp

namespace GodotGame.Services;

public static class TokenStorage
{
    public static string? AccessToken { get; set; }
    public static string? IdToken { get; set; }
    public static string? RefreshToken { get; set; }
    public static Dictionary<string, string> UserClaims { get; set; } = new();

    public static bool IsAuthenticated => !string.IsNullOrEmpty(AccessToken);

    public static void Clear()
    {
        AccessToken = null;
        IdToken = null;
        RefreshToken = null;
        UserClaims.Clear();
    }
}

This stores tokens in memory for the lifetime of the process. For shipped games, consider encrypted local files, the OS keychain, or a backend service. The right choice depends on your platform and security requirements.

3d. Building the Authorize URL

Csharp

public static async Task<string?> BuildAuthorizeUrl(string codeChallenge, string state)
{
    var disco = await GetDiscoveryDocumentAsync();
    if (disco == null) return null;

    var request = new RequestUrl(disco.AuthorizeEndpoint!);
    return request.CreateAuthorizeUrl(
        clientId: BrowserClientId,
        responseType: OidcConstants.ResponseTypes.Code,
        scope: Scopes,
        redirectUri: RedirectUri,
        state: state,
        codeChallenge: codeChallenge,
        codeChallengeMethod: OidcConstants.CodeChallengeMethods.Sha256
    );
}

3e. Exchanging the Code for Tokens

Csharp

public static async Task<bool> ExchangeCodeForTokensAsync(string code, string codeVerifier)
{
    var disco = await GetDiscoveryDocumentAsync();
    if (disco == null) return false;

    using var client = new HttpClient();
    var tokenResponse = await client.RequestAuthorizationCodeTokenAsync(
        new AuthorizationCodeTokenRequest
        {
            Address = disco.TokenEndpoint,
            ClientId = BrowserClientId,
            Code = code,
            RedirectUri = RedirectUri,
            CodeVerifier = codeVerifier
        });

    if (tokenResponse.IsError)
    {
        Console.Error.WriteLine($"[OAuthService] Token exchange error: {tokenResponse.Error}");
        return false;
    }

    StoreTokens(tokenResponse);
    return true;
}

3f. Device Authorization and Polling

Csharp

public static async Task<DeviceAuthorizationResponse?> RequestDeviceAuthorizationAsync()
{
    var disco = await GetDiscoveryDocumentAsync();
    if (disco == null) return null;

    using var client = new HttpClient();
    var response = await client.RequestDeviceAuthorizationAsync(
        new DeviceAuthorizationRequest
        {
            Address = disco.DeviceAuthorizationEndpoint,
            ClientId = DeviceClientId,
            Scope = Scopes
        });

    if (response.IsError)
    {
        Console.Error.WriteLine($"[OAuthService] Device auth error: {response.Error}");
        return null;
    }

    return response;
}

public static async Task<DevicePollResult> PollForDeviceTokenAsync(string deviceCode)
{
    var disco = await GetDiscoveryDocumentAsync();
    if (disco == null) return DevicePollResult.Error;

    using var client = new HttpClient();
    var tokenResponse = await client.RequestDeviceTokenAsync(
        new DeviceTokenRequest
        {
            Address = disco.TokenEndpoint,
            ClientId = DeviceClientId,
            DeviceCode = deviceCode
        });

    if (!tokenResponse.IsError)
    {
        StoreTokens(tokenResponse);
        return DevicePollResult.Success;
    }

    if (tokenResponse.Error == OidcConstants.TokenErrors.AuthorizationPending)
        return DevicePollResult.Pending;
    if (tokenResponse.Error == OidcConstants.TokenErrors.SlowDown)
        return DevicePollResult.SlowDown;

    return DevicePollResult.Error;
}

3g. Fetching the User Profile

Csharp

public static async Task FetchUserInfoAsync()
{
    var disco = await GetDiscoveryDocumentAsync();
    if (disco == null) return;

    if (string.IsNullOrEmpty(TokenStorage.AccessToken)) return;

    using var client = new HttpClient();
    var response = await client.GetUserInfoAsync(new UserInfoRequest
    {
        Address = disco.UserInfoEndpoint,
        Token = TokenStorage.AccessToken
    });

    if (response.IsError) return;

    foreach (var claim in response.Claims)
    {
        TokenStorage.UserClaims[claim.Type] = claim.Value;
    }
}

Step 4: Browser Authorization (PKCE Flow)

Browser Authorization

Here’s BrowserAuthFlow.cs:

Csharp

using System.Net;
using Godot;
using GodotGame.Services;
using Duende.IdentityModel;

namespace GodotGame;

public partial class BrowserAuthFlow : Control
{
    private HttpListener? _listener;
    private string? _codeVerifier;
    private bool _waitingForCallback;

    private Label _statusLabel = null!;
    private Button _startButton = null!;

    public override void _Ready()
    {
        _statusLabel = GetNode<Label>("%StatusLabel");
        _startButton = GetNode<Button>("%StartButton");

        _startButton.Pressed += OnStartLogin;
        GetNode<Button>("%BackButton").Pressed += OnBack;
    }

    private async void OnStartLogin()
    {
        _startButton.Disabled = true;
        UpdateStatus("Generating PKCE challenge...");

        // 1. Generate PKCE verifier + challenge
        var (verifier, challenge) = OAuthService.GeneratePkce();
        _codeVerifier = verifier;

        // 2. Start the local HttpListener to capture the callback
        _listener = new HttpListener();
        _listener.Prefixes.Add("http://localhost:8948/");
        _listener.Start();

        // 3. Build the authorization URL and open the system browser
        var state = CryptoRandom.CreateUniqueId(16);
        var authorizeUrl = await OAuthService.BuildAuthorizeUrl(challenge, state);

        if (authorizeUrl == null)
        {
            UpdateStatus("Error: Could not build authorize URL. Is IdentityServer running?");
            CleanupListener();
            _startButton.Disabled = false;
            return;
        }

        OS.ShellOpen(authorizeUrl);
        UpdateStatus("Waiting for browser callback...\n(Complete login in your browser)");

        // 4. Await the callback — does not block the Godot main thread
        HttpListenerContext context;
        try
        {
            context = await _listener.GetContextAsync();
        }
        catch (Exception ex)
        {
            UpdateStatus($"Cancelled or error: {ex.Message}");
            CleanupListener();
            _startButton.Disabled = false;
            return;
        }

        // 5. Extract authorization code from the query string
        var query = context.Request.QueryString;
        var code = query["code"];
        var returnedState = query["state"];
        var error = query["error"];

        // 6. Respond to the browser so the player knows they can close the tab
        const string html = """
            <!DOCTYPE html>
            <html><body style="font-family:sans-serif;display:flex;align-items:center;
            justify-content:center;height:100vh;margin:0">
            <div style="text-align:center">
            <h1>Authentication Complete!</h1>
            <p>You can close this tab and return to the game.</p>
            </div></body></html>
            """;
        var buffer = System.Text.Encoding.UTF8.GetBytes(html);
        context.Response.ContentType = "text/html";
        context.Response.ContentLength64 = buffer.Length;
        await context.Response.OutputStream.WriteAsync(buffer);
        context.Response.Close();
        CleanupListener();

        // 7. Handle errors
        if (!string.IsNullOrEmpty(error))
        {
            UpdateStatus($"Login error: {error}");
            _startButton.Disabled = false;
            return;
        }

        // 8. Validate state to prevent CSRF attacks
        if (returnedState != state)
        {
            UpdateStatus("Security error: State mismatch!");
            _startButton.Disabled = false;
            return;
        }

        if (string.IsNullOrEmpty(code))
        {
            UpdateStatus("Error: No authorization code received.");
            _startButton.Disabled = false;
            return;
        }

        // 9. Exchange the authorization code for tokens
        UpdateStatus("Exchanging code for tokens...");
        var success = await OAuthService.ExchangeCodeForTokensAsync(code, _codeVerifier!);

        if (success)
        {
            // 10. Fetch user profile and navigate to the authenticated scene
            UpdateStatus("Fetching user profile...");
            await OAuthService.FetchUserInfoAsync();
            GetTree().ChangeSceneToFile("res://Scenes/Authenticated.tscn");
        }
        else
        {
            UpdateStatus("Error: Token exchange failed. Check the console for details.");
            _startButton.Disabled = false;
        }
    }

    private void OnBack()
    {
        CleanupListener();
        GetTree().ChangeSceneToFile("res://Scenes/MainMenu.tscn");
    }

    private void UpdateStatus(string message) => _statusLabel.Text = message;

    private void CleanupListener()
    {
        if (_listener?.IsListening == true)
        {
            _listener.Stop();
            _listener.Close();
        }
        _listener = null;
    }

    public override void _ExitTree()
    {
        _waitingForCallback = false;
        CleanupListener();
    }
}

A few things worth noting in this script:

  • OS.ShellOpen(authorizeUrl)
    Godot’s cross-platform way to open a URL in the default browser. Works on Windows, macOS, and Linux.
  • await _listener.GetContextAsync()
    This is an async call, so it doesn’t block the Godot main thread. The game remains responsive while waiting for the browser callback.
  • State validation (step 8)
    The state parameter prevents cross-site request forgery. We generate a random value, send it with the authorize request, and verify it comes back unchanged in the callback.
  • _ExitTree cleanup
    If the player navigates away mid-flow, we shut down the listener to free the port.
Login using OpenID Connect

Step 5: Device Authorization (Device Flow)

Instead of opening a browser directly, this flow displays a short code and a URL. The player goes to that URL on any device (their phone, a laptop) and enters the code. Meanwhile, the game polls the token endpoint until authorization completes.

Use this flow on hardware without a built-in browser, such as consoles, VR headsets, smart TVs, or arcade cabinets.

5a. Generating a QR Code

Csharp

using Godot;
using QRCoder;

namespace GodotGame.Services;

public static class QrCodeService
{
    public static ImageTexture? GenerateQrTexture(string text, int pixelsPerModule = 10)
    {
        try
        {
            using var generator = new QRCodeGenerator();
            using var qrCodeData = generator.CreateQrCode(text, QRCodeGenerator.ECCLevel.M);

            var pngQr = new PngByteQRCode(qrCodeData);
            var pngBytes = pngQr.GetGraphic(pixelsPerModule);

            // Load the PNG bytes into a Godot Image
            var image = new Image();
            var error = image.LoadPngFromBuffer(pngBytes);
            if (error != Error.Ok) return null;

            return ImageTexture.CreateFromImage(image);
        }
        catch (Exception ex)
        {
            Console.Error.WriteLine($"[QrCodeService] QR generation error: {ex.Message}");
            return null;
        }
    }
}

QRCoder generates the QR code as PNG bytes in memory, which we load into a Godot ImageTexture and assign to a TextureRect.

5b. The Device Flow Script

Csharp

using Godot;
using GodotGame.Services;

namespace GodotGame;

public partial class DeviceAuthFlow : Control
{
    private volatile bool _polling;
    private CancellationTokenSource? _cts;
    private int _pollInterval = 5;
    private string? _verificationUriComplete;

    private Label _statusLabel = null!;
    private Label _urlLabel = null!;
    private Label _codeLabel = null!;
    private TextureRect _qrRect = null!;
    private Button _openBrowserButton = null!;

    public override async void _Ready()
    {
        _statusLabel = GetNode<Label>("%StatusLabel");
        _urlLabel = GetNode<Label>("%UrlLabel");
        _codeLabel = GetNode<Label>("%CodeLabel");
        _qrRect = GetNode<TextureRect>("%QrRect");
        _openBrowserButton = GetNode<Button>("%OpenBrowserButton");

        _openBrowserButton.Pressed += OnOpenBrowser;
        _openBrowserButton.Visible = false;
        GetNode<Button>("%BackButton").Pressed += OnBack;

        UpdateStatus("Requesting device code...");

        // 1. Request device authorization
        var deviceAuth = await OAuthService.RequestDeviceAuthorizationAsync();

        if (deviceAuth == null)
        {
            UpdateStatus("Error: Could not start device flow.\nIs IdentityServer running?");
            return;
        }

        // 2. Display the user code and verification URL
        _urlLabel.Text = deviceAuth.VerificationUri ?? "https://localhost:5001/device";
        _codeLabel.Text = deviceAuth.UserCode ?? "------";
        _verificationUriComplete = deviceAuth.VerificationUriComplete
            ?? $"{deviceAuth.VerificationUri}?userCode={deviceAuth.UserCode}";
        _openBrowserButton.Visible = true;

        // 3. Generate and display QR code
        var qrTexture = QrCodeService.GenerateQrTexture(_verificationUriComplete, pixelsPerModule: 8);
        if (qrTexture != null)
            _qrRect.Texture = qrTexture;

        UpdateStatus("Scan the QR code or enter the code, then log in.");

        // 4. Start background polling
        _pollInterval = deviceAuth.Interval > 0 ? deviceAuth.Interval : 5;
        _polling = true;
        _cts = new CancellationTokenSource();

        var deviceCode = deviceAuth.DeviceCode!;
        _ = Task.Run(() => PollForTokenAsync(deviceCode, _cts.Token), _cts.Token);
    }

    private async Task PollForTokenAsync(string deviceCode, CancellationToken ct)
    {
        while (_polling && !ct.IsCancellationRequested)
        {
            await Task.Delay(TimeSpan.FromSeconds(_pollInterval), ct)
                .ConfigureAwait(false);

            if (ct.IsCancellationRequested) return;

            var result = await OAuthService.PollForDeviceTokenAsync(deviceCode)
                .ConfigureAwait(false);

            switch (result)
            {
                case DevicePollResult.Success:
                    _polling = false;
                    await OAuthService.FetchUserInfoAsync().ConfigureAwait(false);
                    CallDeferred(MethodName.NavigateToAuthenticated);
                    return;

                case DevicePollResult.Pending:
                    CallDeferred(MethodName.UpdateStatus, "Waiting for authorization...");
                    break;

                case DevicePollResult.SlowDown:
                    _pollInterval += 5;
                    CallDeferred(MethodName.UpdateStatus,
                        $"Waiting... (polling every {_pollInterval}s)");
                    break;

                case DevicePollResult.Error:
                    _polling = false;
                    CallDeferred(MethodName.ShowPollError);
                    return;
            }
        }
    }

    private void NavigateToAuthenticated()
    {
        GetTree().ChangeSceneToFile("res://Scenes/Authenticated.tscn");
    }

    private void ShowPollError()
    {
        UpdateStatus("Error: Authorization failed or code expired.\nPress Back and try again.");
        _openBrowserButton.Visible = false;
    }

    private void OnOpenBrowser()
    {
        if (!string.IsNullOrEmpty(_verificationUriComplete))
            OS.ShellOpen(_verificationUriComplete);
    }

    private void OnBack()
    {
        StopPolling();
        GetTree().ChangeSceneToFile("res://Scenes/MainMenu.tscn");
    }

    private void UpdateStatus(string message) => _statusLabel.Text = message;

    private void StopPolling()
    {
        _polling = false;
        _cts?.Cancel();
        _cts?.Dispose();
        _cts = null;
    }

    public override void _ExitTree() => StopPolling();
}

The polling loop handles four outcomes:

  • Pending: The player hasn’t authorized yet. Keep polling at the current interval.
  • SlowDown: The server is asking us to back off. We increase the poll interval by 5 seconds as the spec requires.
  • Success: Tokens received. Fetch the user profile and navigate to the authenticated scene.
  • Error: The code expired, or the player has been denied access.

Notice the use of CallDeferred. The polling loop runs on a background thread via Task.Run, but Godot requires that all scene tree and UI updates occur on the main thread. CallDeferred queues the method call for the next main thread frame.

Devide Code Login in Godot Game Engine

Note: For the QR code to work, your Duende IdentityServer instance’s URL must be accessible via your local network or publicly. You can change the URL from localhost to a more accessible one to test it on a third-party device.

Step 6: Display the Authenticated Player

Csharp

using Godot;
using GodotGame.Services;

namespace GodotGame;

public partial class AuthenticatedScene : Control
{
    public override void _Ready()
    {
        var claims = TokenStorage.UserClaims;

        GetNode<Label>("%NameLabel").Text = $"Name: {claims.GetValueOrDefault("name", "N/A")}";
        GetNode<Label>("%SubLabel").Text = $"Subject: {claims.GetValueOrDefault("sub", "N/A")}";
        GetNode<Label>("%EmailLabel").Text = $"Email: {claims.GetValueOrDefault("email", "N/A")}";

        var name = claims.GetValueOrDefault("name", "User");
        GetNode<Label>("%GreetingLabel").Text = $"Welcome, {name}!";

        GetNode<Button>("%LogoutButton").Pressed += OnLogout;
    }

    private void OnLogout()
    {
        TokenStorage.Clear();
        GetTree().ChangeSceneToFile("res://Scenes/MainMenu.tscn");
    }
}

In production, you’d also call the IdentityServer revocation endpoint (/connect/revocation) before clearing local storage.

Authentication complete

Running the Full Sample

Shell

# Terminal 1: Start IdentityServer
dotnet run --project src/IdentityServer

# Terminal 2: Run the Godot game (or press F5 in the editor)
godot --path src/GodotGame

Test with either of the username-password combinations: alice / alice, bob / bob.

Where to Go from Here

  • Add refresh tokens: Set AllowOfflineAccess = true on the client and handle token renewal in OAuthService. This lets sessions survive game restarts without re-prompting for login. (docs)
  • Protect a game API: Define an API scope in IdentityServer and use the access token to call your backend — leaderboard submissions, cloud save sync, matchmaking services. (docs)
  • Persist tokens: Replace TokenStorage with encrypted file storage or the OS keychain to keep players logged in between sessions.
  • Read the source: Full source on GitHub, available to run and test.

Related Articles