Authentication

The platform API accepts two credentials, each sent as a bearer token:

Authorization: Bearer <credential>
Credential Held by Looks like Lasts
Server key Your game servers and backend gmsk_<key id>_<secret> Until you revoke it
Player access token A player's game client A signed JWT (eyJ...) 1 hour

Each credential belongs to exactly one environment, and every request made with it acts in that environment. There is one API address for all of them: the credential decides where a request goes.

Some routes need a server key, some need a player's token, and some take either. A request with the wrong kind is refused with forbidden. The API reference says which each route takes.

Server keys

A server key is how your game server proves it is yours. With it, your server can sign players in, read and rename them, read config and flags, and report how a session's match is going.

  • Per environment. A key made in test works only in test. Make a separate key for each environment your servers run in.
  • Shown once. The console shows the whole key when you make it. Only a hash of it is stored, so nobody, GreenMind included, can show it to you again.
  • Named. Give each key a name that says where it is used (eu-west match servers), so you know which to revoke. The console shows a few characters of each key's secret, when it was made, and when it was last used (to within an hour).
  • Revocable. Revoking a key takes effect within moments, on every API server at once.
  • Limited. An environment can hold as many live keys as your org's plan allows (the console's Usage page says how many). Revoked keys do not count.

To rotate a key without downtime: make a new key, deploy it to your servers, then revoke the old one.

Never ship a server key in a game client, a web page, or anything else a player can open. Anyone holding it can sign in as any of your players. If a key leaks, revoke it at once.

Player tokens

A player's client holds two tokens, issued together whenever the player signs in:

Token Lasts Used for
Access token 1 hour The bearer token on every request the client makes
Refresh token 30 days Getting a new pair with POST /auth/refresh

Both come back in the same shape:

{
  "accessToken": "eyJhbGciOiJFZERTQSIs...",
  "refreshToken": "eyJhbGciOiJFZERTQSIs...",
  "expiresIn": 3600,
  "player": {
    "id": "0199a7c3-0b2d-7e11-8c55-5f0e3d9a4b21",
    "displayName": "Ada",
    "createdAt": "2026-09-29T12:00:00.000Z"
  }
}

expiresIn is the access token's lifetime in seconds. Refresh a little before it runs out:

curl -X POST https://api.greenmindmedia.com/api/platform/v1/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{ "refreshToken": "eyJhbGciOiJFZERTQSIs..." }'
async function refresh(refreshToken: string) {
  const response = await fetch('https://api.greenmindmedia.com/api/platform/v1/auth/refresh', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ refreshToken }),
  })
  if (response.status === 401) return null // sign the player in again
  return (await response.json()) as { accessToken: string; refreshToken: string; expiresIn: number }
}

Each refresh returns a new refresh token, good for 30 days from then. Keep the newest one. When a refresh is refused with unauthorized, the player has to sign in again.

A game client has no need to read its tokens: keep them as strings, and store them as you would a password, in the platform's secure storage where one exists.

Signing a player out everywhere

POST /players/:playerId/sign-out ends every token a player holds, access and refresh tokens alike, on every device, within moments. Your server calls it with the player's id (for a lost device, an account you think is stolen, or a ban you enforce); a client can call it as POST /players/me/sign-out, for a "sign out of every device" button. Staff can do the same from the player's page in the console. Nothing the player owns or saved is touched, and their next sign-in starts afresh.

A token issued before the sign-out is refused with unauthorized from then on, including by POST /auth/refresh, so a stolen refresh token stops working too.

Checking a player's token on your server

To learn which player a client is, have the client send your server its access token. Your server can then check it in one of two ways.

Ask the API. Call GET /players/me with the token. A 200 answer is the player; a 401 means the token is not valid. This also tells you the player still exists and has not been signed out everywhere, which a check on your own cannot.

Check it yourself, offline. Tokens are JWTs signed with Ed25519 (alg EdDSA), and the public keys are published at GET /.well-known/jwks.json, so your server can check a token with no request to us, once it has the keys. A token is valid for your environment when:

Check Must be
Signature Valid, by the key in the JWKS whose kid the token's header names; alg EdDSA
aud gm-platform
type access. A refresh token is never a credential
game Your environment's app key. A token for your test environment is not one for production
exp In the future (seconds since 1970, UTC). Allow a few seconds for clock skew

sub is then the player's id. Fetch the JWKS once and cache it; fetch it again when a token names a kid you do not have, since that is how you learn of a new key. During a key change the set holds both the new key and the old one.

In Node, the jose library does all of that:

import { createRemoteJWKSet, jwtVerify } from 'jose'

// Cached, and fetched again when a token names a key it does not have.
const keys = createRemoteJWKSet(
  new URL('https://api.greenmindmedia.com/api/platform/v1/.well-known/jwks.json'),
)

/** The player an access token names, or null when it is not a valid token for this environment. */
async function playerOf(accessToken: string, appKey: string): Promise<string | null> {
  try {
    const { payload } = await jwtVerify(accessToken, keys, {
      algorithms: ['EdDSA'],
      audience: 'gm-platform',
    })
    if (payload.type !== 'access' || payload.game !== appKey) return null
    return payload.sub ?? null
  } catch {
    return null
  }
}

In C++ (an Unreal dedicated server, say), any Ed25519 implementation will do: OpenSSL 1.1.1 or later, which Unreal Engine's OpenSSL module provides on desktop platforms, or libsodium. The signature is over the token's first two parts and the dot between them, exactly as sent; decode the JWK's x and the token's third part from base64url (FBase64::Decode with EBase64Mode::UrlSafe) to get the 32-byte key and the 64-byte signature:

#include <openssl/evp.h>

// PublicKey: the JWK's x, decoded (32 bytes). Signed: "<header>.<payload>" as sent.
// Signature: the token's third part, decoded (64 bytes).
bool VerifyEd25519(const TArray<uint8>& PublicKey, const FString& Signed, const TArray<uint8>& Signature)
{
	EVP_PKEY* Key = EVP_PKEY_new_raw_public_key(EVP_PKEY_ED25519, nullptr, PublicKey.GetData(), PublicKey.Num());
	EVP_MD_CTX* Context = EVP_MD_CTX_new();
	const FTCHARToUTF8 Bytes(*Signed);
	const bool bValid = Key && Context
		&& EVP_DigestVerifyInit(Context, nullptr, nullptr, nullptr, Key) == 1
		&& EVP_DigestVerify(Context, Signature.GetData(), Signature.Num(),
			reinterpret_cast<const uint8*>(Bytes.Get()), Bytes.Length()) == 1;
	EVP_MD_CTX_free(Context);
	EVP_PKEY_free(Key);
	return bValid;
}

Then decode the payload (base64url, then JSON) and make the checks in the table above. Check the signature first: until it is valid, nothing in the token can be trusted.

An offline check cannot know that a player was deleted, or signed out everywhere, in the last hour: their token stays valid to it until it expires. Where that matters, ask the API.

Where tokens come from

A player gets tokens in one of two ways, described in Players:

  • your server signs them in with POST /auth/server and passes the tokens to the client, or
  • the client signs in itself with an EOS id token through POST /auth/eos, where the environment accepts EOS.