ESO Login Server

Between 2016 and 2023 I reverse-engineered the login-server protocol the Elder Scrolls Online PC/Mac client uses to authenticate, and wrote several implementations.

ℹ️ Note

This is NOT a game server, this is a login server – the thing that points the client to the game server. I’m releasing this in the event someone finds it useful.

The code is unavailable, but in August 2026, I documented their behaviour, where they converge/diverge, and combined it all into a specification. It covers the transport and envelope, result and status codes, the login flow end to end, every endpoint, golden test vectors, and the raw request/response captures – around 77 pages.

It is deliberately a partial specification: the login endpoints are gone from the live service (404 since ~2023), so this documents the protocol as it was, not as it is.

Download the specification (PDF)

What’s inside

The proxy

To capture the traffic in the first place I pointed the client at a local HTTP proxy that forwards to the real servers. The client-to-proxy leg is plain HTTP, so you can sniff the whole exchange in Wireshark – the upstream leg is HTTPS, so there’d be nothing to see without it. It strips the /us, /eu and /custsupt path prefixes, and substitutes a real client User-Agent when the incoming one doesn’t look like eso/... – the live service rejects anything else (HTTP 409, Client version not supported). The standalone source, so you can copy/paste:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
package main

import (
	"encoding/xml"
	"flag"
	"fmt"
	"net/http"
	"net/http/httputil"
	"net/url"
	"os"
	"strings"
)

const (
	NAHost       = "live-services.elderscrollsonline.com"
	EUHost       = "live-eu-services.elderscrollsonline.com"
	CustHost     = "api-prod.cs.bethesda.net"
	UserAgent    = "eso/8.3.8.2669019 (live; public; client; win)"
	PlatformsXML = "Platforms.xml"
)

func generatePlatforms(filename string, hostPort string) error {
	type platform struct {
		Name               string `xml:"name"`
		LoginServiceUrl    string `xml:"login_service_url"`
		CustomerServiceUrl string `xml:"customer_service_url"`
		Default            bool   `xml:"default"`
		DefaultRealmId     uint   `xml:"default_realm_id"`
	}

	type platforms struct {
		XMLName   interface{} `xml:"platforms"`
		Platforms []platform  `xml:"platform"`
	}

	if filename == "" {
		return nil
	}

	custUrl := (&url.URL{Scheme: "http", Host: hostPort, Path: "/custsupt/prod"}).String()
	rawplat, err := xml.MarshalIndent(platforms{Platforms: []platform{
		{
			Name:               "Live",
			LoginServiceUrl:    "https://live-services.elderscrollsonline.com",
			CustomerServiceUrl: "https://api-prod.cs.bethesda.net/prod",
			Default:            false,
			DefaultRealmId:     4000,
		},
		{
			Name:               "Live-EU",
			LoginServiceUrl:    "https://live-eu-services.elderscrollsonline.com",
			CustomerServiceUrl: "https://api-prod.cs.bethesda.net/prod",
			Default:            false,
			DefaultRealmId:     4001,
		},
		{
			Name:               "Proxy (NA)",
			LoginServiceUrl:    (&url.URL{Scheme: "http", Host: hostPort, Path: "/us"}).String(),
			CustomerServiceUrl: custUrl,
			Default:            false,
			DefaultRealmId:     4000,
		},
		{
			Name:               "Proxy (EU)",
			LoginServiceUrl:    (&url.URL{Scheme: "http", Host: hostPort, Path: "/eu"}).String(),
			CustomerServiceUrl: custUrl,
			Default:            false,
			DefaultRealmId:     4001,
		},
	}}, "", "    ")

	if err != nil {
		return err
	}

	return os.WriteFile(filename, append([]byte(xml.Header), rawplat...), 0644)
}

func makeHandler(prefix string, host string, userAgent string) http.Handler {
	return &httputil.ReverseProxy{Rewrite: func(req *httputil.ProxyRequest) {
		out := req.Out

		out.URL.Scheme = "https"
		out.URL.Host = host
		out.URL.Path = strings.TrimPrefix(out.URL.Path, prefix)

		out.Header.Set("Host", host)
		out.Host = host

		if ua, ok := out.Header["User-Agent"]; !ok || !strings.HasPrefix(ua[0], "eso/") {
			out.Header.Set("User-Agent", userAgent)
		}
	}}
}

func main() {
	userAgent := flag.String("user-agent", UserAgent, "user agent")
	platformXML := flag.String("platform-file", PlatformsXML, "Platforms.xml output file (empty to disable)")
	listenAddress := flag.String("listen-address", "127.0.0.1:8080", "http listen address")
	flag.Parse()

	if err := generatePlatforms(*platformXML, *listenAddress); err != nil {
		fmt.Printf("WARNING: unable to generate Platforms.xml: %v", err)
	}

	mux := http.NewServeMux()
	mux.Handle("/us/", makeHandler("/us", NAHost, *userAgent))
	mux.Handle("/eu/", makeHandler("/eu", EUHost, *userAgent))
	mux.Handle("/custsupt/", makeHandler("/custsupt", CustHost, *userAgent))

	if err := http.ListenAndServe(*listenAddress, mux); err != nil {
		panic(err)
	}
}

Since the login endpoints have since been retired, this is about as close to a written record of the 2016-2023-era protocol as you’re likely to find anywhere.