Authentication
Unchained Engine uses stateless JWT sessions. On login, the engine signs an HS256 JWT and delivers it as an HTTP-only cookie — the token never appears in the GraphQL response. Machine clients authenticate through the Authorization header with an opaque access token or an OIDC bearer token.
| Strategy | Use Case | Entry Point |
|---|---|---|
| Guest | Anonymous cart & checkout | loginAsGuest |
| Email/Password | Traditional registration | createUser, loginWithPassword |
| WebAuthn | Passkeys, biometrics, security keys | loginWithWebAuthn |
| OIDC | External identity providers | authConfig.oidcProviders + bearer token |
| Access Token | Machine-to-machine | modules.users.createAccessToken |
Session Tokens
On every successful login (loginWithPassword, loginAsGuest, createUser, loginWithWebAuthn, resetPassword, verifyEmail, impersonate), the engine:
- Signs a JWT (HS256, via jose) with
sub(user ID),ver(token version),jti, andissclaims. - Sets it as an HTTP-only cookie (
unchained_tokenby default).
The mutation response only exposes the expiry, not the token:
mutation Login {
loginWithPassword(email: "user@example.com", password: "securepassword") {
_id
tokenExpires
user {
_id
primaryEmail {
address
}
}
}
}
Send subsequent requests with cookies included (credentials: 'include' with fetch).
Configuration
| Environment Variable | Default | Description |
|---|---|---|
UNCHAINED_TOKEN_SECRET | — (required) | HS256 signing secret, minimum 32 characters |
UNCHAINED_TOKEN_EXPIRY_SECONDS | 3600 | JWT and cookie lifetime |
UNCHAINED_TOKEN_ISSUER | unchained-engine | iss claim, validated on verification |
UNCHAINED_COOKIE_NAME | unchained_token | JWT cookie name |
UNCHAINED_COOKIE_PATH | / | Cookie path |
UNCHAINED_COOKIE_DOMAIN | — | Cookie domain |
UNCHAINED_COOKIE_SAMESITE | lax | strict, lax, none, 1 (true) or 0 (false) |
UNCHAINED_COOKIE_INSECURE | — | Set to drop the Secure flag (development only) |
Generate a secret:
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
For local storefront development against a remote engine, the server adapters accept a dev-only escape hatch (it throws if NODE_ENV=production):
await connect(fastify, platform, {
allowRemoteToLocalhostSecureCookies: process.env.NODE_ENV !== 'production',
});
Logout
logout clears both cookies, but the JWT itself stays cryptographically valid until it expires. logoutAllSessions increments the user's server-side token version, immediately invalidating every JWT ever issued to that user:
mutation {
logout {
success
}
}
mutation {
logoutAllSessions {
success
}
}
Guest Users
| Type | Can Browse | Can Add to Cart | Can Checkout |
|---|---|---|---|
| Anonymous | Yes | No | No |
| Guest | Yes | Yes | Yes |
| Registered | Yes | Yes | Yes |
Anonymous visitors can browse products and assortments without any session. State-changing operations (cart, checkout) require at least a guest session:
mutation LoginAsGuest {
loginAsGuest {
_id
tokenExpires
}
}
loginAsGuest creates an anonymous user (flagged guest: true) and logs it in like any other user. From there, addCartProduct and checkoutCart work normally — the cart itself is created on the first cart mutation.
Note that createUser always creates a fresh account: registering during a guest session does not carry the guest's cart or order history over to the new account.
Email/Password
Registration
mutation CreateUser {
createUser(
email: "user@example.com"
password: "securepassword"
profile: { displayName: "John Doe" }
) {
_id
tokenExpires
user {
_id
}
}
}
Either username or email is required, plus either password or webAuthnPublicKeyCredentials. The very first user created on an empty system automatically gets the admin role.
Login
mutation Login {
loginWithPassword(email: "user@example.com", password: "securepassword") {
_id
tokenExpires
}
}
Password Reset
mutation {
forgotPassword(email: "user@example.com") {
success
}
}
mutation {
resetPassword(token: "reset-token-from-email", newPassword: "newpassword") {
_id
tokenExpires
}
}
Change Password
mutation {
changePassword(oldPassword: "currentpassword", newPassword: "newpassword") {
success
}
}
WebAuthn (Passwordless)
The relying party ID and origin are derived from ROOT_URL (default http://localhost:4010); the display name comes from EMAIL_WEBSITE_NAME (default Unchained).
Registration Flow
- Get creation options (returns JSON with challenge, rp, user, pubKeyCredParams):
mutation {
createWebAuthnCredentialCreationOptions(username: "user@example.com")
}
- Create the credential with the browser WebAuthn API:
const credential = await navigator.credentials.create({
publicKey: creationOptions,
});
- Store it:
mutation AddWebAuthnCredentials($credentials: JSON!) {
addWebAuthnCredentials(credentials: $credentials) {
_id
webAuthnCredentials {
_id
}
}
}
Login Flow
- Get request options:
mutation {
createWebAuthnCredentialRequestOptions(username: "user@example.com")
}
- Authenticate with the browser WebAuthn API:
const credential = await navigator.credentials.get({
publicKey: requestOptions,
});
- Verify and log in:
mutation LoginWithWebAuthn($credentials: JSON!) {
loginWithWebAuthn(webAuthnPublicKeyCredentials: $credentials) {
_id
tokenExpires
}
}
OIDC (External Identity Providers)
Register trusted providers when connecting the server adapter. Bearer JWTs issued by those providers are then verified against the provider's JWKS (issuer and optional audience validation) and mapped to a user:
import { connect } from '@unchainedshop/api/fastify';
await connect(fastify, platform, {
authConfig: {
oidcProviders: [
{
issuer: 'https://auth.example.com',
// optional: defaults to the jwks_uri of the issuer's OIDC discovery document
// (falls back to `${issuer}/.well-known/jwks.json` when discovery is unavailable)
jwksUri: 'https://auth.example.com/oauth/v2/keys',
// optional audience validation: the token's `aud` must contain it
audience: 'my-client-id',
// optional: map the token's `sub` to your Unchained user id (defaults to `sub`)
userIdFromSubject: (sub) => `my-client-id:${sub}`,
},
],
},
});
When oidcProviders is configured, an OIDC back-channel logout route is mounted automatically at /backchannel-logout. It verifies the provider's logout token, resolves the user through userIdFromSubject and invalidates all of the user's Unchained tokens. Configure ROOT_URL/backchannel-logout as the back-channel logout URL of your client at the identity provider; it has to be reachable from the identity provider.
The browser-facing login flow (authorization redirect, code exchange, user provisioning) is implemented with custom resolvers via startPlatform's context parameter. See the OIDC example for complete Keycloak and Zitadel setups.
Access Tokens (Machine-to-Machine)
For server-to-server access, create an opaque access token programmatically (for example in your boot script):
const result = await platform.unchainedAPI.modules.users.createAccessToken('admin');
if (result) {
console.log(result.token); // only available at creation time
}
Only the SHA-256 hash of the token is stored (services.token.secret). Use it as a bearer token:
Authorization: Bearer <token>
A user has at most one access token; calling createAccessToken again replaces it.
me { tokens } and the invalidateToken mutation in the GraphQL API refer to tokenized products (warehousing/NFT domain), not authentication tokens.
Impersonation
Users with the admin role can impersonate non-admin users (impersonating another admin is rejected):
mutation {
impersonate(userId: "user-id") {
_id
user {
_id
}
}
}
While impersonating, impersonator { _id } returns the acting admin. End the impersonation and resume the admin session with:
mutation {
stopImpersonation {
_id
user {
_id
}
}
}
Cryptography
| Operation | Algorithm |
|---|---|
| Password hashing | PBKDF2-SHA512, 300,000 iterations, 16-byte random salt |
| Session tokens | HS256 JWT (jose) in an HttpOnly cookie |
| Access token storage | SHA-256 hash of a CSPRNG-generated token |
Enforce a custom password policy through the users module options:
await startPlatform({
options: {
users: {
validatePassword: async (password) => password.length >= 12,
},
},
});
Related
- Permissions Reference - Roles, permission actions, and custom roles
- Security Guide - Security features and compliance
- Users Module - User configuration options
- Admin UI - Admin UI overview