Authenticating Players in Godot 4 with OAuth 2.0 and OpenID Connect
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"| IDPPrerequisites
| 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 | |
| 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/IdentityServerThe template includes login, consent, and device verification pages by default. Our screenshots show a customized theme; the default template UI behaves the same way.

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 generationStep 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)

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 anasynccall, so it doesn’t block the Godot main thread. The game remains responsive while waiting for the browser callback. - State validation (step 8)
Thestateparameter 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. -
_ExitTreecleanup
If the player navigates away mid-flow, we shut down the listener to free the port.

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.

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.

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 = trueon the client and handle token renewal inOAuthService. 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
TokenStoragewith 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.