Authentication
Authentication in Starfish is optional. A server may require clients to present credentials during the handshake; when it does not, clients connect anonymously and the flow is identical to an unauthenticated deployment.
The protocol defines only an opaque, extensible credential envelope and the handshake flow — it does not prescribe credential schemes or validation logic. The server owns all validation; clients simply carry a credential to it. This keeps Starfish agnostic to how you actually authenticate: a shared secret, an opaque bearer token, a JWT, or a lookup against your own user database.
See protocol spec §3.4 for the normative definition.
The handshake flow
Authentication happens during the connection handshake, before the server assigns a client ID. There are no extra round-trips — the credential rides along in the client.hello and the server accepts or rejects in its server.welcome.
Client Server
│ │
│ client.hello │
│ { auth: { type: "token", │
│ token: "…" }, project } │
│ ──────────────────────────────────────▶│
│ │ validate auth envelope
│ │
│ server.welcome (success) │
│ { clientId, auth: { required: true }, │
│ resumeToken, … } │
│ ◀──────────────────────────────────────│
│ │If validation fails, the server replies with a welcome-error frame instead of a success welcome, and the client is never registered (it holds no server state).
│ welcome error │
│ { status: "error", │
│ error: { code: "auth.failed", │
│ retry: false } } │
│ ◀──────────────────────────────────────│Server advertisement
Every server.welcome payload carries an additive auth object advertising whether the server requires credentials:
"auth": { "required": true }A missing auth object in the welcome means { "required": false }. Clients can read this to decide whether to prompt for credentials before retrying.
The auth envelope
The client carries an auth object in its client.hello, keyed by type:
"auth": {
"type": "token",
"token": "eyJhbGciOi..."
}type(string, required) identifies the credential scheme. The reserved value"none"— or an omittedauthobject — means the client is presenting no credentials.- Additional fields carry the credential for that scheme.
The protocol does not prescribe schemes beyond none. Two common conventions are documented so servers and clients agree on field names:
type | Fields | Notes |
|---|---|---|
none | — | Anonymous. Default when auth is omitted. |
token | token (string) | Opaque bearer token or JWT. |
shared-secret | secret (string) | Pre-shared secret common to all clients. |
Servers may define additional custom type values. Both sides ignore auth fields they do not understand rather than failing, so schemes can evolve without breaking older peers.
Rejections
When a server requires authentication and validation does not pass, it rejects the fresh handshake with one of two error codes. Both are welcome-response error frames (resource: "client", method: "welcome", kind: "response", payload.status: "error", retry: false):
| Code | Meaning |
|---|---|
auth.required | The client presented no credentials (auth omitted or none). |
auth.failed | The client presented credentials that failed validation. |
{
"header": {
"v": 1,
"id": "err_002",
"resource": "client",
"method": "welcome",
"kind": "response",
"replyTo": "msg_001"
},
"payload": {
"status": "error",
"error": {
"code": "auth.required",
"resource": "client",
"message": "Authentication required.",
"retry": false
}
}
}A third code, project.forbidden, is returned when a credential is valid but maps to a different Project than the one the client declared — see Project resolution.
Reconnection
Resumed connections are not re-challenged. The resumeToken issued in a prior welcome is itself a bearer credential scoped to the resume window, so a hello carrying a valid resumeToken bypasses auth validation. Once the resume window expires, the client performs a fresh handshake and re-authenticates. SDK reconnection (see Configuration → reconnect) handles this transparently as long as it reconnects within the window.
Transport security
wss:// / TLS required
Credentials travel in the client.hello payload as plaintext within the WebSocket frame. Whenever authentication is in use, clients and servers must use a secure transport (wss:// / TLS). Sending a token over ws:// exposes it to anyone on the network path.
Two additional server obligations:
- Compare secrets and tokens using a constant-time comparison to avoid timing side channels. The built-in token validators below already do this.
- Never log credential values.
Configuring a server validator
Each server exposes an Authenticator seam on its config. The default is an allow-all authenticator (no credentials required, declared project honored), so authentication is off until you supply one.
| TypeScript | Go | Python | |
|---|---|---|---|
| Config field | StarfishConfig.authenticator | Config.Authenticator | StarfishConfig.authenticator |
| Default | new AllowAllAuthenticator() | AllowAllAuthenticator{} | AllowAllAuthenticator() |
| Validate method | authenticate(req) → AuthResult | Promise | Authenticate(req) → AuthResult (sync) | authenticate(req) → AuthResult | Awaitable |
| "required" advertised via | required: boolean field | optional AuthAdvertiser.RequiresAuth() | required: bool attribute |
The validator receives the entire auth envelope (not just a token) plus the client's declared project, and returns a result carrying either the effective project (on success) or a rejection code.
Built-in constant-time token validator
Each server ships a TokenAuthenticator that checks the conventional token scheme against a single expected token using a constant-time comparison. This is the quickest way to lock down a deployment with one shared bearer token.
import { StarfishServer, defaultConfig, TokenAuthenticator } from "@starfish/server";
const config = defaultConfig();
config.authenticator = new TokenAuthenticator("s3cret");
const server = new StarfishServer(config);
await server.start();config := starfish.DefaultConfig()
config.Authenticator = starfish.NewTokenAuthenticator("s3cret")
server := starfish.NewServer(config)
http.Handle("/", server)
http.ListenAndServe(config.Addr, nil)from starfish_server import StarfishServer, default_config
from starfish_server.auth import TokenAuthenticator
config = default_config()
config.authenticator = TokenAuthenticator("s3cret")
server = StarfishServer(config)
await server.serve_forever()One token, not a set
The built-in TokenAuthenticator validates against a single expected token. To accept multiple tokens, per-user tokens, JWTs, or a database lookup, write a custom authenticator (below).
Writing a custom validator
Implement the Authenticator seam to plug in any scheme — verify a JWT signature, check an HMAC, or look the token up in your database. TypeScript and Python may return a promise/awaitable, so async I/O (a DB or network call) is fine; Go's Authenticate is synchronous.
import type { Authenticator, AuthRequest, AuthResult } from "@starfish/server";
class JwtAuthenticator implements Authenticator {
readonly required = true;
async authenticate(req: AuthRequest): Promise<AuthResult> {
const token = req.auth?.token as string | undefined;
if (!token || req.auth?.type !== "token") {
return { ok: false, code: "auth.required" };
}
try {
const claims = await verifyJwt(token); // your verification
return { ok: true, project: claims.project ?? req.project };
} catch {
return { ok: false, code: "auth.failed" };
}
}
}
config.authenticator = new JwtAuthenticator();type JWTAuthenticator struct{}
// Advertise that credentials are required (drives welcome auth.required).
func (JWTAuthenticator) RequiresAuth() bool { return true }
func (JWTAuthenticator) Authenticate(req starfish.AuthRequest) starfish.AuthResult {
token, _ := req.Auth["token"].(string)
if token == "" {
return starfish.AuthResult{OK: false, Code: starfish.ErrAuthRequired}
}
claims, err := verifyJWT(token) // your verification
if err != nil {
return starfish.AuthResult{OK: false, Code: starfish.ErrAuthFailed}
}
return starfish.AuthResult{OK: true, Project: claims.Project}
}
config.Authenticator = JWTAuthenticator{}from starfish_server.auth import AuthRequest, AuthResult
from starfish_server.errors import ERR_AUTH_REQUIRED, ERR_AUTH_FAILED
class JwtAuthenticator:
required = True
async def authenticate(self, request: AuthRequest) -> AuthResult:
auth = request.auth or {}
token = auth.get("token")
if not token or auth.get("type") != "token":
return AuthResult(ok=False, code=ERR_AUTH_REQUIRED)
try:
claims = await verify_jwt(token) # your verification
except Exception:
return AuthResult(ok=False, code=ERR_AUTH_FAILED)
return AuthResult(ok=True, project=claims.get("project", request.project))
config.authenticator = JwtAuthenticator()Return an AuthResult with:
ok: trueand the effectiveproject(echoreq.projectif you don't derive one from the credential), orok: falseand acodeofauth.required(no credential) orauth.failed(bad credential).
In Go, also implement the optional AuthAdvertiser interface (RequiresAuth() bool) so the welcome advertises auth.required: true; without it the server reports auth as not required. In TypeScript and Python, set the required field/attribute.
Projects and authentication
Authentication and Project isolation are separate concerns but compose: your authenticator may derive or constrain the effective Project from the credential by returning a different project in the AuthResult. A credential that resolves to one Project but declares a different one is rejected with project.forbidden. This is how a deployment mints one Project per API key.
Passing a token from a client SDK
Set the auth option when constructing the client. If validation fails, connect() raises an error whose code is auth.required or auth.failed.
import { StarfishClient } from "@starfish/client";
const client = new StarfishClient({
server: "wss://example.com",
webSocketFactory: (url) => new WebSocket(url),
auth: { type: "token", token: "s3cret" },
});
try {
await client.connect();
} catch (err) {
if (err.code === "auth.required" || err.code === "auth.failed") {
// prompt for credentials and retry
}
}client := starfish.NewClient(starfish.ClientOptions{
Server: "wss://example.com",
Auth: &starfish.AuthOptions{Type: "token", Token: "s3cret"},
})
if err := client.Connect(context.Background()); err != nil {
var sfErr *starfish.Error
if errors.As(err, &sfErr) && (sfErr.Code == "auth.required" || sfErr.Code == "auth.failed") {
// prompt for credentials and retry
}
}from starfish import StarfishClient, StarfishClientOptions, AuthOptions
from starfish.pending import StarfishRequestError
client = StarfishClient(StarfishClientOptions(
server="wss://example.com",
auth=AuthOptions(type="token", token="s3cret"),
))
try:
await client.connect()
except StarfishRequestError as err:
if err.code in ("auth.required", "auth.failed"):
... # prompt for credentials and retryimport StarfishClient
let client = StarfishClient(options: StarfishClientOptions(
server: URL(string: "wss://example.com")!,
auth: AuthOptions(type: "token", token: "s3cret")
))
do {
_ = try await client.connect()
} catch let error as StarfishError {
if error.serverErrorCode == .authRequired || error.serverErrorCode == .authFailed {
// prompt for credentials and retry
}
}Custom schemes
The TypeScript and Go clients carry arbitrary scheme fields alongside type, so you can send a shared-secret (or any custom scheme) as long as the server understands it:
auth: { type: "shared-secret", secret: "hunter2" }Auth: &starfish.AuthOptions{
Type: "shared-secret",
Fields: map[string]any{"secret": "hunter2"},
}The Python and Swift clients currently transmit only the type and token fields, so they support the token scheme (with any type string) but cannot carry extra scheme-specific fields like secret.
JVM / Kotlin
The JVM/Kotlin SDK exposes an AuthConfig option, but its handshake does not yet transmit the token — authentication is not functional there. Track this gap before relying on auth from a JVM client.
See also
- Configuration → server
authenticator— the config field reference. - Configuration → client
auth— the client option reference. - Projects — isolation scopes that compose with auth.