Godot quickstart

The Godot addon signs players in without a browser inside your game. Players approve a short code on their phone or PC, or start as guests and add an email address, Discord or Steam account later. It mirrors the Unity package.

Before you start

  • A Game (device sign-in) application in the console. Tickoffline_access under Scopes so players stay signed in between five-minute access tokens. See Console onboarding.
  • Its Client ID, and the Project ID from project settings.
  • Godot 4.3 or later. The addon is verified on Godot 4.7.

1. Add the addon

The addon lives in packages/sdk-godot/addons/auth4. Copy that folder into your project's addons/ folder:

mkdir -p your-game/addons
cp -r packages/sdk-godot/addons/auth4 your-game/addons/

Its classes are global, so you can use them straight away. Enabling auth4.dev Authentication under Project Settings > Plugins only lists it with its version. Native applications are public clients: there is no client secret to embed, and the addon can't send one.

2. Create the client

From the example project in examples/godot:

Excerpt from examples/godot/main.gd
	var options := Auth4ClientOptions.new()
	options.tenant_id = tenant_id
	options.client_id = client_id
	options.auth_origin = auth_origin
	options.redirect_uri = redirect_uri
	_client = Auth4Client.new(options)
	if not _client.configuration_error.is_empty():
		_show("Configure the example in the Inspector: " + _client.configuration_error)
		return
	add_child(_client)
	_client.session_changed.connect(_on_session_changed)

auth_origin is the origin of your issuer, without /t/…. HTTP is only accepted for loopback addresses during local development. Invalid options leave a message inconfiguration_error, and every call then fails. Add the client to the scene tree, as a child or an autoload: its default transport uses HTTPRequest nodes.

GDScript has no exceptions, so every call is a coroutine that returns an Auth4Result. Check ok, then read value. On failure, error holds the OAuth error code and reason says how a flow ended. description is for developers: don't show it to players.

3. Sign in with another device

sign_in_with_device starts device authorization, passes you the code to show, and polls until the player approves, denies or the code expires:

Excerpt from examples/godot/main.gd
func _sign_in_with_device() -> void:
	_cancel = Auth4CancelToken.new()
	_show("Waiting for approval…", [["Cancel", _cancel.cancel]])
	var result := await _client.sign_in_with_device(_show_code, "", _cancel)
	_code.text = ""
	if result.ok:
		return
	match result.reason:
		Auth4Result.CANCELLED:
			_on_session_changed(null)
			_status.text = "Sign-in cancelled."
		Auth4Result.DENIED:
			_on_session_changed(null)
			_status.text = "Sign-in was denied."
		Auth4Result.EXPIRED:
			_on_session_changed(null)
			_status.text = "The code expired."
		_:
			_on_session_changed(null)
			_status.text = "Sign-in failed: " + result.error


func _show_code(authorization: Auth4DeviceAuthorization) -> void:
	_code.text = authorization.user_code
	_status.text = "Open %s on another device and enter the code." % authorization.verification_uri


# Web exports only. The popup opens before the first await, inside the button's input event.
func _sign_in_with_browser() -> void:
	_cancel = Auth4CancelToken.new()
	_show("Finish signing in in the window that opened.", [["Cancel", _cancel.cancel]])
	var result := await _client.sign_in_with_browser("", "", _cancel)
	if result.ok:
		return
	_on_session_changed(null)
	match result.error:
		"popup_blocked":
			_status.text = "Allow pop-ups for this page, then try again."
		"window_closed", "cancelled":
			_status.text = "Sign-in cancelled."
		"access_denied":
			_status.text = "Sign-in was denied."
		_:
			_status.text = "Sign-in failed: " + result.error

Show user_code and verification_uri large enough to read from a couch.verification_uri_complete pre-fills the code. The default scope is openid offline_access. Pass an Auth4CancelToken to let the player stop waiting; a cancelled authorization can be polled again with poll_device_authorization until it expires. Polling, cancellation and expiry are covered in Device sign-in.

The code length, its lifetime and the polling interval come from the application's device settings. The addon reads them from each authorization, so changing them in the console needs no new build.

4. Guest play and upgrades

upgrade_guest lets the player link an email address, a Discord account or a Steam account in their system browser. It opens the hosted page withOS.shell_open (or your browser_launcher) and polls until the player finishes. Pass "steam" or "discord" to offer that method first.

Guest sign-inExcerpt from examples/godot/main.gd
func _play_as_guest() -> void:
	var result := await _client.sign_in_as_guest()
	if not result.ok:
		_status.text = "Guest sign-in failed: " + result.error
Saving guest progressExcerpt from examples/godot/main.gd
func _save_guest_progress(provider: String) -> void:
	_cancel = Auth4CancelToken.new()
	_show("Finish linking in the browser, then return here.", [["Cancel", _cancel.cancel]])
	var on_progress := func(progress: Auth4GuestUpgradeProgress) -> void:
		if progress.stage == Auth4GuestUpgradeProgress.Stage.WAITING_FOR_PLAYER:
			_status.text = "Waiting for the browser…"
	var result := await _client.upgrade_guest(provider, on_progress, _cancel)
	if result.ok:
		_status.text = "Progress saved with %s." % result.value.provider
		return
	_on_session_changed(_client.get_current_session())
	match result.reason:
		Auth4Result.CONFLICT:
			_status.text = "That account already belongs to another player. Still playing as guest."
		Auth4Result.DENIED:
			_status.text = "Not saved. Still playing as guest."
		Auth4Result.EXPIRED:
			_status.text = "The link expired. Try again."
		Auth4Result.CANCELLED:
			_status.text = "Stopped waiting. If you finished in the browser, sign in with that account."
		_:
			if result.error == "popup_blocked":
				_status.text = "Allow pop-ups for this page, then try again."
			elif result.error == "window_closed":
				_status.text = "Not saved. Still playing as guest."
			else:
				_status.text = "Saving failed: " + result.error
  • Polling waits the server's interval (at least five seconds) and slows down when the server asks. The page link expires after ten minutes.
  • On success the tokens are replaced in one step, in memory and in secure storage, andsession_changed fires once. The user ID doesn't change.
  • progress.upgrade_uri is safe to show or reopen: it holds only a public handle. The secret that collects the tokens never leaves the game.
  • If you cancel after the player has already finished in the browser, the account is still upgraded. The guest session is ended, so the player signs in with the account they linked.

To link an email address without leaving the game, run the code steps yourself:

In-game email upgrade
# Later, when the player wants to keep their progress, without leaving the game:
var flow: Auth4GuestUpgradeFlow = (await client.begin_guest_upgrade()).value # about five minutes
await client.start_email_verification(flow, email)                          # sends a six-digit code
var proof := await client.confirm_email_verification(flow, email, code)
if not proof.ok:
	return show_error(proof.error)                                          # for example invalid_code
var result := await client.complete_guest_upgrade(flow, proof.value)
if result.ok:
	pass # result.value.session.user_id is the guest's. The session and refresh token were replaced.
elif result.reason == Auth4Result.CONFLICT:
	pass # The email already belongs to another player. Nothing changed and nothing was merged.
  • Sign-in calls fail with invalid_state while a session is active, so a guest is never abandoned silently. Upgrade the guest, or call sign_out first.
  • Your project needs Guest accounts and Email verification codes turned on.
  • Read Guest accounts before shipping guest play.

5. Call your game server

Attaching the access token
var token := await client.get_access_token() # refreshes shortly before expiry
if token.ok:
	headers.append("Authorization: Bearer " + token.value)
elif token.reason == Auth4Result.SESSION_EXPIRED:
	show_sign_in_menu() # the refresh token was rejected and the session is already cleared
elif token.is_transient():
	retry_later()       # network or server problem; the session is kept

Concurrent calls share one refresh request, so parallel requests never trigger refresh-token reuse detection. If a refresh response is lost on a poor connection, retrying with the same token within 30 seconds still works (see the reuse window). Treat the token as opaque in the game and verify it on your server with the server SDK, using the game application's client ID as the audience.

sign_out revokes the refresh token and always clears local state. It returnsfalse if the server couldn't confirm revocation, for example when the player is offline.

6. Keep players signed in across restarts (optional)

By default the refresh token lives only in memory, so players sign in again after restarting the game. Godot has no built-in protected store, so the addon ships none. To persist sessions, extend Auth4SecureStorage over your platform's protected store, such as the iOS Keychain, Android Keystore-backed encryption, Windows DPAPI or a console's protected save API, usually through a GDExtension or a platform plugin:

Excerpt from packages/sdk-godot/addons/auth4/auth4_secure_storage.gd
## Returns the stored value, or null when the key is absent.
func read(_key: String) -> Variant:
	push_error("Auth4SecureStorage.read() is not implemented.")
	return null


## Returns true once the value is stored.
func write(_key: String, _value: String) -> bool:
	push_error("Auth4SecureStorage.write() is not implemented.")
	return false


## Returns true once the key is absent.
func delete(_key: String) -> bool:
	push_error("Auth4SecureStorage.delete() is not implemented.")
	return false
Restoring a session at start-up
var options := Auth4ClientOptions.new()
options.tenant_id = "ten_…"
options.client_id = "cli_…"
options.auth_origin = "https://auth.auth4.dev"
options.secure_storage = MyKeychainStorage.new() # extends Auth4SecureStorage
var client := Auth4Client.new(options)
add_child(client)

var restored := await client.try_restore_session()
if not restored.ok or not restored.value:
	show_sign_in_menu()

7. Web exports

A web export already runs in a browser, so the addon signs players in and upgrades guests in popup windows, as the browser SDK and Unity WebGL builds do. Tokens stay in memory, and nothing is written to a URL, cookies or browser storage.

  • Use a browser application, not the game (device sign-in) one. Register the page the popup returns to under Redirect URIs and the game's origin under Allowed origins. Any page on the game's origin works; a small blank page such as auth4-callback.html next to index.html loads fastest.
  • To keep refresh tokens, turn on Allow refresh tokens in the browser and add offline_access under Scopes. Otherwise set default_scope to "openid".
Signing in from a web export
var options := Auth4ClientOptions.new()
options.tenant_id = "ten_…"
options.client_id = "cli_…" # a browser application
options.auth_origin = "https://auth.auth4.dev"
options.redirect_uri = "https://game.example/auth4-callback.html" # on the game's own origin
var client := Auth4Client.new(options)
add_child(client)

# In a button handler: the popup opens before the first await, inside the input event.
var signed_in := await client.sign_in_with_browser() # or ("discord") to offer Discord first
if signed_in.ok:
	pass # signed_in.value is the Auth4Session; tokens stay in memory
elif signed_in.error == "popup_blocked":
	ask_to_allow_popups()

sign_in_with_browser uses the authorization code flow with S256 PKCE. The popup goes to hosted login and back to redirect_uri; the addon reads the code from the popup, closes it and exchanges the code in the game. sign_in_as_guest andupgrade_guest work as on other platforms, except that the upgrade page opens in a popup:

  • The addon answers the page's handshake only when the message comes from that popup on the auth origin, and posts the guest access token only to that origin. No upgrade link is created.
  • After CONFLICT the popup stays open so the player can link another account. If they do, the session is still replaced and session_changed fires.
  • Browsers open popups only during an input event, and Godot handles input a frame later. When the browser no longer counts the input, the popup opens on the player's next click or tap. Set the button's action_mode to BaseButton.ACTION_MODE_BUTTON_PRESS, so that the release of the same press opens it.
  • The in-game email steps (begin_guest_upgrade and the calls after it) fail with unsupported_platform, because the browser controls the cookie and Origin headers they need. Device sign-in is for native builds.
  • Export without Thread Support, the default since Godot 4.3. Threaded exports need the Cross-Origin-Opener-Policy: same-origin header, which cuts the game off from its popups.
  • Sessions live in memory, so players sign in again after reloading the page. Don't set secure_storage in a web export.

The popup is opened through web_bridge, which defaults to the addon'sJavaScriptBridge implementation. The export's HTML shell needs no changes; the only extra file is the redirect page.

Errors

ResultMeaning
reason == Auth4Result.DENIEDThe player declined the device code, or chose Not now on the hosted page.
reason == Auth4Result.EXPIREDThe device code or upgrade link timed out. Offer to start again.
reason == Auth4Result.CONFLICTThe email, Discord or Steam account belongs to another player. The guest session is unchanged.
reason == Auth4Result.CANCELLEDYour cancel token fired. A device authorization can be polled again until it expires.
reason == Auth4Result.SESSION_EXPIREDThe refresh token was rejected. The session and stored credentials are already cleared, and session_changed fired with null.
is_transient()A network failure or 5xx response. The session is kept: retry later.
error == "invalid_state"A sign-in was started while a session is active, or an upgrade was attempted without a guest session.
error == "unsupported_platform"sign_in_with_browser() was called outside a web export, or the in-game email steps inside one.
error == "popup_blocked"Web exports: the browser blocked the popup. Ask the player to allow pop-ups.
error == "window_closed"Web exports: the player closed the popup before finishing.
error == "invalid_session"Web exports: the guest session ended while the upgrade popup was open.

Verify the addon

The repository runs the addon headless in Godot against a local stand-in for the auth4.dev API, then loads the example project. A browser suite runs the web popup code against the hosted upgrade page:

GODOT_BIN=/path/to/godot node packages/sdk-godot/tests/run.mjs
pnpm exec playwright test -c tests/e2e/auth4d-219.playwright.config.ts

Sources: packages/sdk-godot/tests/test_client.gd, packages/sdk-godot/tests/mock-server.mjs, examples/godot. Full addon guide: packages/sdk-godot/README.md.