JWT sessions or API keys: two auth paths
Session tokens and API keys authenticate the same endpoints. Use a JWT in a browser app and a vsk_ key for a server job or scheduled script.
Every authenticated Versely request is Authorization: Bearer <token>. The middleware looks at the token and picks a path. If the string starts with vsk_, it is an API key. Otherwise it is a JWT: a Supabase session from a login, or an OAuth access token issued for the MCP connector. Same endpoints, same user id on the request, different lifetimes, different blast radius, different callers. Put a session JWT in a cron and it expires while you sleep. Put a vsk_ key in a browser and you have given the page a credential that does not log out.
The REST surface is on the developer page. Which surface you generate from (MCP, CLI, API, web) is a different question, covered in CLI, MCP, or API and MCP, API or web UI. This is the narrower one: once you are calling https://api.versely.studio/api/v1, which bearer do you send.
How the middleware branches
authenticateUser reads the Bearer token and then:
vsk_prefix. Hash with SHA-256, look upapi_keys, reject if missing,revoked_atset, orexpires_atin the past. Check the catalog scope for the path. Apply per-key RPM. Setreq.user = { id: user_id }.JWT whose
issishttps://api.versely.studio. OAuth access token from the MCP connector flow. Verify locally, require audiencehttps://mcp.versely.studio/mcp, check the token'sscopeagainst the same catalog the API keys use. Setreq.user = { id: sub }.Any other JWT. Treated as a Supabase session. Local checks on expiry (60-second clock-skew leeway), issuer (
<SUPABASE_URL>/auth/v1), and audience (authenticated). A denylist is checked so logout and password change reject the token immediately. A short user cache (60 seconds, never pastexp) avoids a Supabase round-trip on every request. ThengetUseris the authoritative signature check. Setreq.userto that user.
A missing or non-Bearer header is 401. The JSON field name is not stable across paths: the API-key branch uses error, the session JWT branch uses message. Parse both.
After auth, generate routes still run enforceUserId (a body user_id that does not match the token is 403), the cost-sensitive IP limiter, and a credits check. Those do not care which branch you took. The credential's job is to prove who. What that who is allowed to do, and for how long, is where the paths diverge.
Which caller gets which credential
| Caller | Credential | Why |
|---|---|---|
| Browser app, logged-in user | Supabase session JWT | The user can sign out. The token expires. Logout denylists it. You do not want a vsk_ key in DevTools. |
| Native app | Same session JWT | Same logout and expiry story. Refresh is a session problem, not a key-rotation problem. |
| Server job (product image pipeline, overnight render) | API key, vsk_… |
No browser, no refresh dance. Scopes and per-key RPM exist for this. Store the raw value in a secret manager. |
| Scheduled script / cron | API key | A session JWT will expire. A key lives until you revoke it or expires_at hits. |
| CI | API key, low rate_limit_rpm, short expires_at |
Blast radius is the whole point. A session in CI is a human identity in a log. |
| Interactive agent via MCP | OAuth JWT (sign-in) or a key the CLI wrote into agent config | The MCP connector is the sign-in path. The CLI is how a machine gets a durable key without pasting. |
| You, in agent chat on the site | Session JWT | You are a logged-in user. Do not mint a key to talk to yourself in the browser. |
A compact version of the same table for the three questions people actually ask:
Browser app. Session JWT. The token is short-lived, denylisted on logout, cached for at most a minute after validation. Scopes are "this is the user." Do not mint an API key per page load.
Server job. API key with the narrowest scopes the job needs (generate and read for a render worker, post for a scheduler). Set rate_limit_rpm to the loop you are willing to absorb. Put expires_at on anything that is not permanent.
Scheduled script. API key, same as the server job. Cron cannot complete an OAuth redirect. versely auth login on a laptop, then the raw key in the environment the cron actually runs in, is the CLI-shaped version of this. There is no versely generate; the CLI does not replace the script.
MCP looks like a JWT on the wire because it is one, with iss and aud that the session path will refuse. Do not send an MCP access token to a job that is not the MCP resource, and do not send a vsk_ key to a chat client that can already sign in.
What the key can do that the session cannot (and the reverse)
API keys carry the eight-scope catalog, a per-key RPM, an optional expires_at, and a key_prefix you can find in a list when something leaks. They are shown once. Rotation is mint, swap, revoke. They are the right shape for a process that will still be running at 03:00.
Session JWTs carry none of that catalog. They are the user: generate, post, editor export, agent chat, billing reads, the same as the website. They expire. They can be denylisted without rotating a hash in a database. They are the right shape for a person in a UI.
Things people try that do not work well:
- JWT in
VERSELY_API_KEY. It will work untilexp, then the script 401s withToken has expired. The 60-second leeway is for clock skew, not for a week of cron. - API key in a single-page app. XSS now has a credential that does not expire when the user hits log out. Logout denylists JWTs. It does not revoke API keys.
- One
"all"key shared by the web app and the worker. The web app did not needmanage_accounts. The worker did not need the browser's session semantics. Split them. - Creating keys from a key you cannot rotate. Key CRUD authenticates as a user. Prefer a session (or a dedicated management key) so a rotation of the job key cannot lock you out of the management routes.
The skills pack assumes a credential is already present and then teaches the agent how to pre-flight credits and poll status. It does not choose the bearer for you.
FAQ
Do API keys and JWTs hit different endpoints?
No. Both go to https://api.versely.studio/api/v1 with a Bearer token. Generate, status, user, social, workflows: the route list is the same. What differs is how the token is issued, how long it lives, whether it has catalog scopes, and whether it has a per-key RPM.
Can I create an API key with an API key?
Key management is behind authenticateUser, which accepts either a session JWT or a vsk_ key. /auth/api-keys is not owned by a catalog scope, so a valid key is not rejected for missing generate or read. Create and revoke from a session anyway. The job key you are rotating should not be the only credential that can finish the rotation.
What is the third JWT I see in traces, with iss api.versely.studio?
That is an OAuth access token from the MCP connector, audience https://mcp.versely.studio/mcp. It is not a Supabase session. The session path will reject it on issuer (and usually audience). The MCP path will reject a Supabase session on the same checks in the other direction. Send each token to the caller it was issued for.
If I log out, is my API key dead?
No. Logout denylists the session JWT. API keys die when you DELETE /api/v1/auth/api-keys/:keyId (soft revoke via revoked_at) or when expires_at passes. Plan the revoke. A stolen vsk_ key remains valid until you do.