Frontend Authentication
Use short-lived tokens to call Pyth Pro from browser apps
This guide explains how to call Pyth Pro from browser and frontend apps without shipping your PRO_API_KEY to users. Anything in a browser bundle is public, so your backend mints a short-lived JWT with POST /auth/token and hands it to the browser. The browser then presents that JWT in place of the API key — as an Authorization: Bearer header on the History and REST APIs, and through the pyth-lazer-auth subprotocol on a WebSocket connection.
Replacing the Proxy (Beta) API?
This is the replacement. The beta Proxy (pyth-lazer-proxy-{1,2,3}) gave
unauthenticated frontend access; now your backend mints a short-lived JWT and
the browser uses it over the WebSocket, REST, and History APIs — the raw API
key stays server-side.
When to use which pattern
| Client | Pattern |
|---|---|
| Server-to-server | Present the long-lived Pro API key directly as Authorization: Bearer <PRO_API_KEY>. Simplest — prefer this whenever the client can hold a secret. |
| Browser / frontend | Your backend mints a JWT via POST /auth/token and returns it to the browser, which presents it in place of the API key: Authorization: Bearer <JWT> on the History and REST APIs, or the pyth-lazer-auth WebSocket subprotocol for streaming. |
Never expose the long-lived PRO_API_KEY in browser code, mobile apps, or
any distributed binary. Anything in the client bundle is public — treat it
as leaked the moment you ship it.
The flow
- The browser client asks its own backend for a Pyth JWT.
- The backend calls
POST https://pyth.dourolabs.app/auth/tokenwithAuthorization: Bearer <PRO_API_KEY>. - The backend returns the JWT (and its
expires_at) to the browser. - The browser presents the JWT in place of the API key until it nears expiry, then repeats from step 1.
POST /auth/token reference
- Base URL:
https://pyth.dourolabs.app - Method / path:
POST /auth/token - Auth:
Authorization: Bearer <PRO_API_KEY>(the long-lived Pro API key) - Content-Type:
application/json
Request body (optional)
| Field | Type | Required | Description |
|---|---|---|---|
ttl_seconds | integer | null | No | How long the token should live, in seconds. The server caps it at its maximum; omit it (or send null) to get that maximum. |
Response 200
| Field | Type | Description |
|---|---|---|
access_token | string | Short-lived JWT to present as Authorization: Bearer <access_token>. |
expires_at | string | RFC 3339 datetime at which the token expires. |
token_type | string | Always "Bearer". |
Errors
| Status | Meaning |
|---|---|
401 | Missing or malformed API key. |
404 | Token broker is not enabled on this deployment. |
Backend example
Add a token endpoint to your own backend. Never send the raw PRO_API_KEY to the browser — hand out only the short-lived JWT.
import express from "express";
const app = express();
app.use(express.json());
// Requires PRO_API_KEY in the backend's environment.
// Protect this route with your own auth — see "Protecting the token endpoint" below.
app.post("/pyth-token", async (req, res) => {
const body: Record<string, unknown> = {};
if (typeof req.body?.ttl_seconds === "number") {
// Optional pass-through: let callers request a shorter TTL than the server default.
body.ttl_seconds = req.body.ttl_seconds;
}
const response = await fetch("https://pyth.dourolabs.app/auth/token", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PRO_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!response.ok) {
res.status(response.status).send(await response.text());
return;
}
// { access_token, expires_at, token_type }
res.json(await response.json());
});Do not cache the minted JWT on the server across users unless you scope each token to one user. A JWT is a bearer token: whoever holds it can spend your quota until it expires.
Frontend example
Cache the JWT in memory, refresh it a few seconds before expires_at, and send it as a bearer token on each request.
type PythToken = { access_token: string; expires_at: string };
let cached: PythToken | undefined;
async function getPythToken(): Promise<string> {
// Refresh 30s before the token actually expires so an in-flight request
// doesn't race the expiry.
const safetyMarginMs = 30_000;
if (cached && Date.parse(cached.expires_at) - Date.now() > safetyMarginMs) {
return cached.access_token;
}
const response = await fetch("/pyth-token", { method: "POST" });
if (!response.ok) throw new Error(`Failed to mint Pyth token: ${response.status}`);
cached = (await response.json()) as PythToken;
return cached.access_token;
}
// Call the History API from the browser using the short-lived JWT.
const jwt = await getPythToken();
const res = await fetch(
"https://pyth.dourolabs.app/v1/fixed_rate@200ms/price?ids=1×tamp=1704067200000000",
{ headers: { Authorization: `Bearer ${jwt}` } },
);
console.log(await res.json());The same JWT works against the REST API on https://pyth-lazer.dourolabs.app — send it as Authorization: Bearer <JWT>, exactly as on the History API.
For the WebSocket API, a browser cannot set request headers on the connection, so pass the JWT through the subprotocol list instead. Offer the marker pyth-lazer-auth immediately followed by the token:
const ws = new WebSocket(
"wss://pyth-lazer-0.dourolabs.app/v1/stream",
["pyth-lazer-auth", jwt],
);The server reads the token from that value, authenticates, and completes the handshake by echoing back the pyth-lazer-auth subprotocol — your client accepts it and does nothing with it. The rest of the subscribe flow is unchanged.
Protecting the token endpoint
- Require your own auth on
POST /pyth-token(session cookie, first-party JWT, etc.). Anyone who can reach this endpoint can spend your Pro API-key quota. - Rate-limit it. Without a limit, the endpoint is easy to abuse.
- Quota accounting: calls made with a JWT count against the quota of the API key that minted it.