Build
Authentication and tenancy
Exchange credentials for a token, verify it, and resolve tenant context server-side.
Pikbase docs Authentication proves identity. Authorization decides whether that identity may perform an operation on a tenant. They are separate steps and both run on the server.
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;
remember?: boolean;
includeUserInfo?: boolean;
variation?: { tenantCode: string };
}
const response = await entityService.getToken({
email,
password,
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 |
The token encodes the user id (uid), tenant code (tc), instance id (i), expiry (exp), and issuer (iss).
This exchange runs on your server. The password never reaches a browser bundle, and the token never reaches localStorage.
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
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.