Workspaces
Authentication and workspace access
Authenticate your account and authorize access to a workspace, called a tenant in the API.
Authentication proves identity. Authorization decides whether that identity may perform an operation on a workspace (tenant). They are separate steps and both run on the server. The API keeps the name tenantCode: pass the workspace code, not its display name. See workspaces and tenants for the relationship between an account, a workspace, and a SaaS subtenant.
Getting a token
Credentials are exchanged for a token at /api/auth/getToken. The TypeScript client wraps that call:
interface GetTokenRequest {
email: string;
password: string;
mfaToken?: string;
runMfaIfEnabled?: boolean;
remember?: boolean;
includeUserInfo?: boolean;
variation?: { tenantCode: string };
}
const response = await entityService.getToken({
email,
password,
runMfaIfEnabled: true,
remember: true,
includeUserInfo: true,
variation: { tenantCode },
});
const token = response.auth?.token;
The response is a GsbAuthResponse:
| Field | Type | Description |
|---|---|---|
auth.token |
string | Bearer token for subsequent requests |
auth.userId |
string | Identifier of the authenticated user |
auth.tenantCode |
string | Tenant the token was issued for |
auth.roles |
string[] | Role names granted to the user |
auth.groups |
string[] | Group names the user belongs to |
auth.expireDate |
string | Expiry timestamp |
status |
number | HTTP status of the exchange |
Email MFA login
When MFA is enabled for the user, role, or workspace, token issuance is a two-step exchange on the same /api/auth/getToken endpoint.
- Submit the email, password, tenant, and
runMfaIfEnabled: true. - Native authentication verifies the first factor and asks the email issuer to deliver a six-digit code.
- A successful challenge returns HTTP 200 without a session token. Current deployments return the challenge fields inside
auth:
{
"auth": {
"mfaRequired": true,
"mfaEmailSent": true,
"mfaExpireDate": "2030-01-01T12:10:00Z"
}
}
- Submit the same credentials and tenant again with the six-digit
mfaToken. Only a successful second request returnsauth.token.
Use the exported LoginForm inside AuthShellProvider for browser applications. It recognizes both top-level and nested challenge envelopes, replaces the password form with an email-code field, and keeps the original credentials only in component memory. It never writes the password or MFA code to storage, cookies, URLs, auth context, or query caches.
An MFA_VERIFICATION_INVALID response keeps the in-memory challenge so the user can correct the code. Expired, exhausted, canceled, or otherwise terminal challenges clear the credentials and return to the normal sign-in form. Refreshing or leaving the page also requires a fresh sign-in. Applications must not call the MFA email function directly or infer a challenge from generic error text.
The CLI supports the same flow with a secure prompt or --mfa-code-env; see Pikbase CLI.
The token encodes the user id (uid), tenant code (tc), instance id (i), expiry (exp), and issuer (iss).
This is the required server-managed session architecture. The password never reaches a browser bundle, and the token must never reach localStorage.
The current browser client does not provide this architecture: its token configuration can use browser localStorage, and its auth service writes a JavaScript-readable cookie. Treat that as a limitation to remediate at the application/server boundary, not as an HttpOnly session. Do not use the browser client’s storage behavior as a security recommendation.
Storing the session
Set the token in a cookie from a server route. HttpOnly keeps it out of reach of any script, so a single XSS cannot become account takeover.
// Server route. Never runs in the browser.
const auth = await entityService.getToken({
email,
password,
variation: { tenantCode },
});
if (!auth.auth?.token) {
return new Response("Invalid credentials", { status: 401 });
}
return new Response(null, {
status: 204,
headers: {
"Set-Cookie": [
`session=${auth.auth.token}`,
"HttpOnly",
"Secure",
"SameSite=Lax",
"Path=/",
`Max-Age=${60 * 60 * 8}`,
].join("; "),
},
});
Verifying the session
The backend verification endpoint is a POST request. Its body carries tenant context
inside variation, leaving the request extensible for future fields:
const result = await entityService.verifyToken(
{ variation: { tenantCode } },
jwt,
);
When jwt is valid, result.auth includes the verified user's email, roles,
groups, and positions. Omitting the second argument sends no bearer token and still
returns success with the tenant's public user identity and role. A public identity is
not an authenticated session and must not authorize protected operations.
The same operation is available in the API console as /api/auth/verifyToken and in
the CLI:
gsb verify-token --json
gsb verify-token --anonymous --json
Decoding exp and trusting it is not authentication — a forged cookie passes. Verify the signature against the issuer's JWKS on every protected request.
import { createRemoteJWKSet, jwtVerify } from "jose";
const jwks = createRemoteJWKSet(new URL(process.env.GSB_JWKS_URL));
export async function verifySession(request: Request) {
const token = readCookie(request, "session");
if (!token) throw new UnauthorizedError();
const { payload } = await jwtVerify(token, jwks, {
issuer: process.env.GSB_TOKEN_ISSUER,
audience: process.env.GSB_TOKEN_AUDIENCE,
});
// Tenant comes from the verified claim, never from the request.
return {
token,
userId: String(payload.uid),
tenantCode: String(payload.tc),
};
}
jwtVerify checks the signature, exp, nbf, issuer, and audience together. A path-prefix check in middleware is routing, not security.
Authorizing the request
export async function GET(request: Request) {
const session = await verifySession(request);
// A verified identity is not yet an authorized one.
if (!(await canRead(session.userId, session.tenantCode, "Order"))) {
return new Response("Forbidden", { status: 403 });
}
const orders = new QueryParams<Order>("Order").skip(0).take(25);
const page = await entityService.query(
orders,
session.token,
session.tenantCode,
);
return Response.json(page);
}
Pass session.tenantCode explicitly. A tenant code read from a query string, header, or request body is caller-controlled input and must never reach a service call.
Errors
| Status | Body | Cause |
|---|---|---|
| 401 | { "status": 401, "message": "Invalid credentials" } |
Wrong email or password |
| 400 | { "status": 400, "message": "Tenant code is required" } |
variation.tenantCode omitted |
| 401 | { "status": 401, "message": "Token has expired" } |
Expired token; re-authenticate |
Rules
- Server-only secrets. Anything behind
NEXT_PUBLIC_orVITE_is compiled into the browser bundle and is public. HttpOnly; Secure; SameSite=Laxcookies set by a server route. NeverlocalStorage.- Verify the signature against JWKS on every protected request. Never decode and trust.
- Resolve tenant context from the verified claim, then authorize the operation on that tenant.
- Grant the narrowest role that works. Keep read-only inspection separate from mutation.
- Validate every external input with a schema before it reaches a service call.
- Redact tokens, credentials, and personal data from logs. Carry a request id instead.
- Always HTTPS. Handle revocation and expiry explicitly.
Pikbase docs