Skip to content

Server-minted sessions

Use server-minted sessions when your server already authenticates the end user. You hold the bearer token, you know who’s logged in, and you want to hand them a short-lived JWT they can use to call your published /p/* endpoints — without ever shipping the bearer to the browser or the mobile app.

If you instead want PerSQL to do the authentication (signup, login, password hashing), see Session auth.

Your auth storyUse
PerSQL stores users + passwords for youauth_signup / auth_login endpoints
You have your own auth (NextAuth, Clerk, Better Auth, OAuth, custom)POST /v1/sessions (this page)
You’re an agent / CI run / cron, not a per-end-user requestUse the bearer directly; no session needed

From your trusted server (Next.js route handler, Hono worker, Express endpoint — anywhere the bearer lives):

import { PerSQL } from "@persql/sdk";
const persql = new PerSQL({ token: process.env.PERSQL_TOKEN! });
const db = persql.database("acme", "orders");
const session = await db.createSession({
userId: currentUser.id,
email: currentUser.email,
expiresIn: 3600,
});
// Hand `session.token` to the untrusted client.
return Response.json({
persqlSession: session.token,
expiresAt: session.expiresAt,
});
Terminal window
curl -X POST https://api.persql.com/v1/sessions \
-H "Authorization: Bearer $PERSQL_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"namespaceSlug": "acme",
"databaseSlug": "orders",
"userId": "user_42",
"email": "alice@example.com",
"expiresIn": 3600
}'

Response:

{
"success": true,
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expiresAt": "2026-05-16T15:30:00.000Z",
"expiresIn": 3600
}
}

The untrusted client passes the JWT as a bearer header when calling your published endpoints:

const res = await fetch("https://api.persql.com/p/acme/orders/list-mine", {
headers: { Authorization: `Bearer ${persqlSession}` },
});

Endpoints marked auth: "session" will see $user_id bound to the userId you minted the session with. Endpoints marked auth: "public" ignore the session.

FieldRequiredNotes
namespaceSlugyesMust match the bearer token’s namespace.
databaseSlugyesThe session is scoped to this single database.
userIdyesUp to 256 chars. Becomes $user_id on session-bound endpoints.
emailnoUp to 320 chars. Stored on the JWT as the email claim.
expiresInnoSeconds. Default 3600 (1h). Min 60, max 86400 (24h).

POST /v1/sessions requires the bearer to have read access on the target database under your scope rules (an unscoped token always passes; a scoped token must include this database with role ≥ read).

The minted JWT inherits the database scope only — it cannot escalate beyond what the bearer can do.

Sessions are stateless: the JWT validates against the database’s signing key, with no per-session row in the control plane. That means:

  • Short TTLs are the primary revocation mechanism. Default 1h; cap 24h. Mint a fresh token on each page load / app launch if you need finer control.
  • Logout-everywhere is supported by bumping database.tokens_min_iat (a column on the database row). Any JWT whose iat is older than that floor is rejected. The console surfaces this as “Revoke all sessions”.
  • Per-session revocation is not yet exposed via /v1. If you need it, file an issue with your use case.
  • The bearer never leaves your server. Treat it like a database password.
  • The JWT does leave your server but is scoped to one database and one user, with a hard expiry.
  • Sessions don’t bypass token scopes, rate limits, or the per-namespace balance gate. Every /p/* call still bills the namespace that owns the database.
  • The userId is recorded in audit events alongside the bearer token id, so every mutation traces back to the end user.