Skip to main content

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.

StrategyUse CaseEntry Point
GuestAnonymous cart & checkoutloginAsGuest
Email/PasswordTraditional registrationcreateUser, loginWithPassword
WebAuthnPasskeys, biometrics, security keysloginWithWebAuthn
OIDCExternal identity providersauthConfig.oidcProviders + bearer token
Access TokenMachine-to-machinemodules.users.createAccessToken

Session Tokens​

On every successful login (loginWithPassword, loginAsGuest, createUser, loginWithWebAuthn, resetPassword, verifyEmail, impersonate), the engine:

  1. Signs a JWT (HS256, via jose) with sub (user ID), ver (token version), jti, and iss claims.
  2. Sets it as an HTTP-only cookie (unchained_token by 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 VariableDefaultDescription
UNCHAINED_TOKEN_SECRET— (required)HS256 signing secret, minimum 32 characters
UNCHAINED_TOKEN_EXPIRY_SECONDS3600JWT and cookie lifetime
UNCHAINED_TOKEN_ISSUERunchained-engineiss claim, validated on verification
UNCHAINED_COOKIE_NAMEunchained_tokenJWT cookie name
UNCHAINED_COOKIE_PATH/Cookie path
UNCHAINED_COOKIE_DOMAIN—Cookie domain
UNCHAINED_COOKIE_SAMESITElaxstrict, 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​

TypeCan BrowseCan Add to CartCan Checkout
AnonymousYesNoNo
GuestYesYesYes
RegisteredYesYesYes

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​

  1. Get creation options (returns JSON with challenge, rp, user, pubKeyCredParams):
mutation {
createWebAuthnCredentialCreationOptions(username: "user@example.com")
}
  1. Create the credential with the browser WebAuthn API:
const credential = await navigator.credentials.create({
publicKey: creationOptions,
});
  1. Store it:
mutation AddWebAuthnCredentials($credentials: JSON!) {
addWebAuthnCredentials(credentials: $credentials) {
_id
webAuthnCredentials {
_id
}
}
}

Login Flow​

  1. Get request options:
mutation {
createWebAuthnCredentialRequestOptions(username: "user@example.com")
}
  1. Authenticate with the browser WebAuthn API:
const credential = await navigator.credentials.get({
publicKey: requestOptions,
});
  1. 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.

note

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​

OperationAlgorithm
Password hashingPBKDF2-SHA512, 300,000 iterations, 16-byte random salt
Session tokensHS256 JWT (jose) in an HttpOnly cookie
Access token storageSHA-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,
},
},
});