jstash Apps
Give every person who signs in to your app their own private JSON documents. Your app keeps the login it already has; jstash checks it.
- The one rule: a person’s documents can be read and written by that person, and nobody else. There’s no rules language, so there’s nothing to get wrong.
- No server, no secret in the page. Browser code sends the login token your app already has. You never put a jstash API key in a web page.
- Works with Supabase, Firebase, Google and Clerk sign-in. GitHub sign-in works through Supabase, Firebase or Clerk.
- jstash never sees a password and never runs a sign-in. It only checks a signature your login provider already made, using the keys that provider publishes.
Set up an app
- In the dashboard, open Apps → New app and give it a name.
- Pick the login your app uses and paste one value you already have: your Supabase Project URL, Firebase Project ID, Google OAuth client ID, or Clerk Frontend API URL.
- Add your website address, like
https://my-app.netlify.app(up to 5). Turn on Allow localhost while you develop. - Copy the starter code from the app’s page. It already has your app’s address in it.
Setup for your login: Clerk · Supabase · Firebase · Google. Step-by-step guides: per-user storage with Clerk and saving user data from a static website.
Each app has an endpoint like https://api.jstash.app/v1/apps/app_…/me. Documents live under it by name: …/me/players, …/me/settings.
Using a coding agent
Copy this prompt into Claude Code, Codex, Cursor or a chat assistant. It asks you for your app ID and login, then follows apps.md, this guide in Markdown. Once you’ve created an app, its page in the dashboard has a prompt like this with your app’s details filled in.
Help me add per-user cloud save to this app with jstash Apps. Each person who signs in gets their own private JSON documents and can read and write only their own. The app keeps the login it already has: the browser sends the signed-in person's login token, and jstash checks it. There's no server to write, no SDK to install, and no jstash API key in the page.
Before you write any code:
- Read the reference at https://jstash.app/docs/apps.md if you can fetch it. The essentials are below.
- Ask me for my jstash app ID. It starts with app_ and is part of the endpoint at the top of the app's page in the jstash dashboard: https://api.jstash.app/v1/apps/app_…/me. Never make one up. If I don't have an app yet, tell me to create one at https://app.jstash.app/apps (Apps → New app): pick the login, paste the one value it asks for, and add the app's website address; then, on the app's page, add any other addresses and turn on Allow localhost for local development.
- Ask me which login the app uses, unless the code already makes it clear. jstash works with Clerk, Supabase Auth, Firebase Auth and Sign In With Google only; GitHub sign-in works through Clerk, Supabase or Firebase. If the app uses something else, stop and tell me.
Then:
1. Look at the app: how sign-in is set up, and what each person's data is (settings, progress, drafts...). Tell me in a sentence or two what you'll save, under which document names.
2. Add one small module like the starter below, adapted to the app's framework and style. Call load once someone is signed in (null means nothing is saved yet, so use the app's defaults), and call save when the data changes (debounce rapid edits), never on a timer. Drop any pending save when the person signs out, switches account or deletes their account, so their data is never saved under someone else or after deletion. Don't change how sign-in works.
3. Get the login token right before each request; don't store it:
- Clerk: Clerk.session.getToken() (in React, getToken from useAuth()). Use the default session token, not a JWT template. Tokens last 60 seconds; getToken() returns a fresh one when needed. Clerk tokens name the website they were made on, so every address where people sign in must be in the app's website addresses. Clerk development and production instances have different Frontend API URLs; each needs its own jstash app.
- Supabase: (await supabase.auth.getSession()).data.session.access_token, using the app's existing client. data.session is null when nobody is signed in, so don't call jstash then.
- Firebase: auth.currentUser.getIdToken(), using the app's existing getAuth() instance. currentUser is null until sign-in finishes, so wait for onAuthStateChanged.
- Google: the ID token from the Sign In With Google callback (response.credential), kept in memory. It lasts one hour and can't be refreshed in the page, so on 401 AUTH_INVALID ask the person to sign in again.
4. If the app lets people delete their account, also call DELETE on the endpoint (https://api.jstash.app/v1/apps/{appId}/me) with their token as part of it.
5. Browsers are refused from any address that isn't listed under Website addresses on the app's page. Addresses match exactly (scheme, host and port), must use https, have no wildcards, and there can be up to 5; localhost works only with Allow localhost on. If an address the app runs on is missing, tell me exactly which one to add. Code can't change these settings.
6. Add a short jstash section to AGENTS.md (or CLAUDE.md, if that's what this repo uses): where the module is, that it sends the login token and never a jstash API key, and the reference link.
7. Tell me how to test it.
If you can't see or edit this project's files (for example, you're a chat assistant), ask me for the files you need, then give me complete code to paste and say where each piece goes.
Starter module (shown with Clerk; for another login, only token() changes):
```js
const JSTASH = 'https://api.jstash.app/v1/apps/APP_ID/me'; // APP_ID: the app ID I give you
function token() {
if (!Clerk.session) throw new Error('Not signed in');
return Clerk.session.getToken();
}
export async function save(name, value) {
const res = await fetch(`${JSTASH}/${name}`, {
method: 'PUT',
headers: { Authorization: `Bearer ${await token()}` },
body: JSON.stringify(value),
});
if (!res.ok) throw await failure(res);
}
export async function load(name) {
const res = await fetch(`${JSTASH}/${name}`, {
headers: { Authorization: `Bearer ${await token()}` },
});
if (res.ok) return res.json();
const err = await failure(res);
if (err.code === 'DOC_NOT_FOUND') return null; // nothing saved yet
throw err;
}
// jstash errors are JSON: {"error": {"code", "message"}}
async function failure(res) {
const { error } = await res.json()
.catch(() => ({ error: { message: `jstash returned ${res.status}` } }));
return Object.assign(new Error(error.message), { code: error.code });
}
```
## jstash Apps essentials
Every call sends Authorization: Bearer <the signed-in person's login token> and reaches only that person's documents. Never send a jstash API key here: it's refused, and it must never be in browser code.
GET {endpoint} List their documents: {"items": [{"name", "bytes", "version", "etag", "updated_at"}]}.
GET {endpoint}/{name} The document, raw, with an ETag header. 404 DOC_NOT_FOUND: nothing saved yet.
PUT {endpoint}/{name} Create (201) or replace (200) the whole document; the body is any JSON value.
DELETE {endpoint}/{name} Delete it. 204, safe to repeat.
DELETE {endpoint} Delete all of their documents.
Document names: 1-64 characters of letters, digits, - _ and ., not starting with a dot. Use a few fixed names like "settings" or "progress", never user input.
PUT replaces the whole document; there are no partial updates. If the same person might save from two devices at once, send the ETag from your last GET or PUT as If-Match. 412 ETAG_MISMATCH means it changed: load it again, reapply the change and retry.
Limits on Free (Pro in brackets): 200 documents per person, 100 KB each (1 MB). Per person: 100 saves a day (1,000) and 1,000 reads (5,000); 60 saves and 300 reads a minute. Per app: 500 saves a day (50,000) and 5,000 reads (50,000). Up to 100 people across all my jstash apps (1,000). Daily limits reset at 00:00 UTC.
Errors are JSON: {"error": {"code": "...", "message": "..."}, "request_id": "..."}. Branch on code, never on message. Log the message: it says what to fix, but it's written for developers, not for the people using the app.
- 400 INVALID_REQUEST: the document name isn't allowed. 400 INVALID_JSON: the body isn't JSON.
- 401 AUTH_REQUIRED, AUTH_INVALID: no token, or it's expired or not from the app's login.
- 403 FORBIDDEN: the website isn't one of the app's addresses (the message names it), anonymous sign-ins are refused, or the app is read-only.
- 403 APP_LIMIT_REACHED, STORAGE_LIMIT_REACHED: a plan limit. 404 APP_NOT_FOUND: wrong app ID.
- 403 APP_SUSPENDED: the app was taken offline; don't retry.
- 413 PAYLOAD_TOO_LARGE: the document is over the size limit.
- 429 RATE_LIMITED: wait a minute and retry. 429 WRITE_LIMIT_REACHED: a daily limit is used up; don't retry before 00:00 UTC.
- 500 INTERNAL_ERROR, 503 LOGIN_PROVIDER_UNAVAILABLE, R2_UNAVAILABLE, DATABASE_UNAVAILABLE: retry shortly.
jstash keeps no old versions of documents and doesn't restore them on request, so a PUT or DELETE can't be undone. Don't store sensitive personal data such as health information.
Supabase
Paste your Project URL (the Connect button, or Project Settings → Data API). Then send the signed-in user’s access token:
const JSTASH = 'https://api.jstash.app/v1/apps/app_…/me';
async function token() {
const { data } = await supabase.auth.getSession();
return data.session.access_token;
}
export async function save(name, value) {
const res = await fetch(`${JSTASH}/${name}`, {
method: 'PUT',
headers: { Authorization: `Bearer ${await token()}` },
body: JSON.stringify(value),
});
if (!res.ok) throw await failure(res);
}
export async function load(name) {
const res = await fetch(`${JSTASH}/${name}`, {
headers: { Authorization: `Bearer ${await token()}` },
});
if (res.ok) return res.json();
const err = await failure(res);
if (err.code === 'DOC_NOT_FOUND') return null; // nothing saved yet
throw err;
}
// jstash errors are JSON: {"error": {"code", "message"}}
async function failure(res) {
const { error } = await res.json()
.catch(() => ({ error: { message: `jstash returned ${res.status}` } }));
return Object.assign(new Error(error.message), { code: error.code });
}
- Your project needs JWT signing keys. Older Supabase projects sign logins with a shared secret, which jstash would have to hold to check. If yours does, the dashboard says so when you create the app: in Supabase, open Project Settings → JWT Keys and migrate to signing keys. Use an RSA or ECC (P-256) key; a shared-secret (HS256) key can’t be checked either.
- Anonymous sign-ins are refused unless you turn on Allow anonymous users for the app, which needs Pro. Anyone can create those, and each one gets its own storage.
- Supabase refreshes the token for you, so calling
getSession()before each request is enough.
Firebase
Paste your Project ID (Project settings → General). Use the same save and load as above, with this token():
import { getAuth } from 'firebase/auth';
const auth = getAuth();
function token() {
return auth.currentUser.getIdToken(); // Firebase refreshes it every hour
}
Firebase anonymous auth is refused unless you turn on Allow anonymous users, which needs Pro.
Paste the OAuth client ID you use for Sign In With Google. Its callback hands you an ID token in response.credential:
let idToken;
function onSignIn(response) { // your Sign In With Google callback
idToken = response.credential;
}
function token() {
return idToken;
}
Google ID tokens last one hour and the page doesn’t refresh them, so this suits short sessions. For apps people keep open, use Supabase, Firebase or Clerk — all three offer “Sign in with Google” and refresh tokens for you.
Clerk
Paste your Frontend API URL, from the Domains page in Clerk, or your publishable key from API keys — jstash reads the URL out of it. With Clerk loaded, use the same save and load as above, with this token():
function token() {
if (!Clerk.session) throw new Error('Not signed in');
return Clerk.session.getToken(); // Clerk refreshes it for you
}
- Clerk tokens last 60 seconds, so get one for each request, as above.
getToken()returns the token Clerk already has while it’s still good, so that costs nothing. In React, usegetTokenfromuseAuth()instead. - Development and production are separate. Each Clerk instance has its own Frontend API URL and its own users, so a token from your development instance (its URL ends in
.clerk.accounts.dev) doesn’t work with an app set up for production. Use a jstash app for each. - List every website that signs people in. A Clerk token names the website it was made on. jstash refuses it if that website isn’t one of the app’s addresses (or localhost, with Allow localhost on).
- Someone with a step left to finish in Clerk, like choosing an organization or resetting their password, is refused until they finish it. Clerk treats them as signed out too.
- Clerk’s proxy and satellite-domain setups aren’t supported yet.
No login yet?
Add one with Clerk, Supabase Auth or Firebase Authentication. All three have free tiers, take minutes to add, and offer email, Google and GitHub sign-in. jstash doesn’t run sign-in itself: that’s the part best left to a specialist, and it means jstash never holds your users’ passwords or email addresses.
GitHub sign-in goes through Clerk, Supabase or Firebase. GitHub gives apps an access key to the person’s GitHub account rather than a signed login token, so jstash can’t check it directly — and shouldn’t hold it.
Endpoints
Your users’ calls, each with Authorization: Bearer <login token>. They reach only the signed-in person’s documents.
/v1/apps/{appId}/meLogin token · Lists their documents: {"items": [{"name", "bytes", "version", "etag", "updated_at"}]}./v1/apps/{appId}/me/{name}Login token · The document, raw, with its ETag. 404 DOC_NOT_FOUND if nothing is saved yet./v1/apps/{appId}/me/{name}Login token · Creates (201) or replaces (200) it. If-Match for safe updates, If-None-Match: * to create only./v1/apps/{appId}/me/{name}Login token · Deletes it. Safe to repeat./v1/apps/{appId}/meLogin token · Deletes all of their documents — for your app’s “delete my account”.Names are 1–64 characters: letters, digits, -, _ and ., not starting with a dot. Bodies follow the same rules as bins: any JSON value, any Content-Type, stored byte for byte. If-Match works as described in Updating safely.
Your own calls, with your API key or the dashboard:
/v1/appsAPI key · Your apps, each with its users, documents, storage and today’s saves./v1/apps/{appId}API key · One app./v1/apps/{appId}/users/{userId}API key · Deletes one person’s documents, for deletion requests. {userId} is their ID in your login provider (the token’s sub). Returns 204 whether or not anything was stored, so check you used their provider user ID.Creating, changing and deleting apps happens in the dashboard only, so a leaked API key can’t open your app to other websites. You can’t read your users’ documents, and neither can jstash staff through the dashboard.
Website addresses
Browsers send the address of the page making a request. jstash refuses requests from addresses your app doesn’t list, with an error naming the address to add. List the scheme and host only — https://my-app.netlify.app, no path. Addresses must use https.
- Allow localhost accepts
localhostand127.0.0.1on any port, over http or https. Turn it off when you launch. - Native mobile apps and servers don’t send an address, so the list doesn’t affect them. The login token is what protects your users.
- Clerk tokens also name the website they were made on. jstash checks that against the same list, wherever the token is sent from.
Limits
- A user counts once they first save something. Reading doesn’t make someone a user.
- Users count across all your apps together. When they reach your plan’s limit, people already using your apps keep saving; new people get
APP_LIMIT_REACHED. Deleting an app frees its users’ places. - On every plan, each user can also make up to 60 saves, 300 reads and 30 deletes a minute.
- Daily limits reset at 00:00 UTC. Saving an identical document doesn’t count toward them; every read and list counts toward the daily reads, even one that finds nothing.
- If you go from Pro to Free with more than 1 app, all your apps stay readable but stop accepting saves until you delete down to 1. Reading and deleting documents still work. Free’s limits apply from then on, and anonymous sign-ins are refused even if an app has them turned on.
Deleting data
- A person deletes their account in your app: call
DELETE /v1/apps/{appId}/mewith their token. - Someone asks you to delete their data: use Delete user data on the app’s page, or
DELETE /v1/apps/{appId}/users/{userId}with your API key. - You delete the app: it stops working straight away. Every user’s documents are then deleted, usually within two hours (very large apps can take several days).
- You delete your jstash account: your apps go with it.
jstash keeps no old versions of app documents and doesn’t restore them on request.
Errors
Same format as the rest of the API: a stable code and a message that names the problem and, for common errors, what to fix. The messages are safe to show in your app’s console, but are written for you, not your users.
Authorization: Bearer <token> once the person has signed in.If-Match), already exists (If-None-Match: *), or this user’s documents were deleted while it was saving.reset_at. Don’t retry before it. Nothing is deleted.Other codes, like INVALID_JSON and PAYLOAD_TOO_LARGE, mean the same as for bins — see all error codes.
Troubleshooting
“Requests from https://… aren’t allowed for this app.”
Add that exact address under Website addresses on the app’s page. While developing on your own machine, turn on Allow localhost instead.
“This login token was issued on https://…, which isn’t allowed for this app.”
Clerk made the token on a website the app doesn’t list. Add that exact address under Website addresses on the app’s page, or turn on Allow localhost while developing.
“This login token expired…”
Get the token just before each request rather than storing it: supabase.auth.getSession(), auth.currentUser.getIdToken() or Clerk.session.getToken() return a fresh one. Google ID tokens can’t be refreshed in the page; sign the person in again.
“…signs logins with the legacy JWT secret.”
Your Supabase project needs JWT signing keys. In Supabase: Project Settings → JWT Keys, then migrate, and try creating the app again.
“This token is from …, but this app uses …”
The token came from a different Supabase or Firebase project, or Clerk instance, than the app is set up for. Check the app ID in your code, and the Project URL, Project ID or Frontend API URL on the app’s page.
“This token wasn’t signed by Clerk instance …”
The token comes from another Clerk instance, most often development instead of production. They have different Frontend API URLs and different users, so set up one jstash app for each and use the matching app ID.
I changed login provider and my users’ data disappeared.
Documents belong to a person’s identity in one login, so the login can’t change once people have saved data. If you’re moving providers, keep the old app until people have moved, or export through your app first.
Privacy
- jstash stores each person’s documents and when they were saved, under a one-way hash of their user ID. It never stores their password, email address or login token.
- Your users’ login tokens pass through jstash. A token your app sends also works with your login provider — for example your Supabase project — until it expires: within a minute for Clerk, usually within an hour for the others. jstash uses it only to check who is signed in. It isn’t stored or logged. That’s the trade-off of not running a server: you trust jstash with the token for each request.
- You’re responsible to your users: tell them what your app stores and why, and get any consent the law needs — see the Terms.
- Our Data Processing Agreement (DPA) covers your users’ data. It’s part of the Terms, so there’s nothing separate to sign; keep an email address on your jstash account so you get its notices.
- Don’t store sensitive personal data in an app, such as health, genetic or biometric data, or someone’s ethnicity, religion, political views, sexuality or criminal record — see the Terms.