@tuwaio/siwx-server
@tuwaio/siwx-server is the server Layer 2 (L2) package of SIWX (Sign-In With X), the authentication project of TUWA Stage 1 (“Core Auth & Primitives”, next to Orbit Utils). Built on @tuwaio/siwx-core and the Web platform APIs (Request, Response, Web Crypto), it verifies signed CAIP-122 messages for EVM and Solana on your backend, issues single-use nonces and sessions through pluggable stores, and formats the session cookie. It runs on Node.js 20+ and does not depend on a web framework, a database or a hosted service.
It has two entry points:
@tuwaio/siwx-server: framework-agnostic verification, session and cookie utilities.@tuwaio/siwx-server/next: ready-made route handlers for a Next.js App Router catch-all route. They use onlyRequestandResponse, so they do not importnext.
🏛️ Core Capabilities
- Verification:
verifySiwxPayloadparses the message, validates it against yourSiwxVerificationPolicyand routes the signature check by chain to@tuwaio/siwx-evm(EIP-191, with an EIP-1271 fallback whenpublicClientis set) or@tuwaio/siwx-solana(ed25519). It returns a result instead of throwing. - Durable profile:
createSiwxApiHandlerserves the nonce, verify, session and logout routes on top of yourSiwxSessionStoreandSiwxNonceStore(Redis, SQL, KV…), with the session ID in anHttpOnlycookie. - Stateless demo profile:
createStatelessDemoSiwxHandlerissues HMAC-signed nonces and keeps the session in an HMAC-signed cookie, for demos without a database. - Server-side session:
getSiwxServerSessionreads the session from aRequest,Headers, aCookieheader or the Next.jscookies()store, for Server Actions and API routes. - Building blocks:
MemorySiwxSessionStoreandMemorySiwxNonceStorefor development and tests, cookie helpers, demo token and demo nonce signing, and re-exports of the@tuwaio/siwx-corevalidators.
💾 Installation
pnpm add @tuwaio/siwx-server @tuwaio/siwx-core
# Chain verifiers, loaded at runtime for the chains you accept:
pnpm add @tuwaio/siwx-evm viem @wagmi/core
pnpm add @tuwaio/siwx-solana @solana/kit @wallet-standard/base[!IMPORTANT]
@tuwaio/siwx-coreis a required peer dependency.@tuwaio/siwx-evm,@tuwaio/siwx-solanaandviemare optional peer dependencies:verifySiwxPayloadimports the chain package dynamically, depending on the chain of the message, so install the ones you accept together with their own peer dependencies. A message for a chain whose package is missing fails verification.viemprovides thePublicClienttype of the EIP-1271 option.
🚀 Usage
1. Session and nonce stores
The durable profile needs a session store and a nonce store shared by every server instance. A Redis implementation (the client shape matches ioredis):
// lib/authStores.ts
import {
generateServerNonce,
type SiwxNonceStore,
type SiwxSessionRecord,
type SiwxSessionStore,
} from '@tuwaio/siwx-server';
declare const redis: {
set(key: string, value: string, mode: 'EX', seconds: number): Promise<unknown>;
get(key: string): Promise<string | null>;
getdel(key: string): Promise<string | null>;
del(key: string): Promise<unknown>;
};
export const sessionStore: SiwxSessionStore = {
async create({ session, ttlSeconds }) {
const createdAt = Date.now();
const record: SiwxSessionRecord = {
id: generateServerNonce(), // 128-bit random ID, becomes the cookie value
session,
createdAt,
expiresAt: createdAt + ttlSeconds * 1000,
};
await redis.set(`siwx:session:${record.id}`, JSON.stringify(record), 'EX', ttlSeconds);
return record;
},
async get(id) {
const data = await redis.get(`siwx:session:${id}`); // expired keys are removed by Redis
return data ? (JSON.parse(data) as SiwxSessionRecord) : null;
},
async bindSubject(id, subjectId) {
const record = await this.get(id);
if (!record) return false;
const ttlSeconds = Math.max(1, Math.floor((record.expiresAt - Date.now()) / 1000));
await redis.set(`siwx:session:${id}`, JSON.stringify({ ...record, subjectId }), 'EX', ttlSeconds);
return true;
},
async revoke(id) {
await redis.del(`siwx:session:${id}`);
},
};
export const nonceStore: SiwxNonceStore = {
async issue({ nonce, ttlSeconds }) {
await redis.set(`siwx:nonce:${nonce}`, '1', 'EX', ttlSeconds);
},
async consume({ nonce }) {
return (await redis.getdel(`siwx:nonce:${nonce}`)) !== null; // atomic: each nonce is accepted once
},
};For local development and tests, use new MemorySiwxSessionStore() and new MemorySiwxNonceStore(). They keep data in the memory of one process and throw when NODE_ENV is production, unless you pass { allowInProduction: true }.
2. Route handlers (Next.js App Router)
// app/api/siwx/[...siwx]/route.ts
import { createSiwxApiHandler } from '@tuwaio/siwx-server/next';
import { nonceStore, sessionStore } from '@/lib/authStores';
export const { GET, POST, DELETE } = createSiwxApiHandler({
sessionStore,
nonceStore,
policy: {
expectedDomain: 'app.tuwa.io',
expectedUri: 'https://app.tuwa.io',
allowedChainIds: ['eip155:1', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpK'],
requireExpirationTime: true,
maxIssuedAtAgeSeconds: 300,
},
ttlSeconds: 7 * 24 * 60 * 60, // session and cookie lifetime (default)
});The handler picks the action from the last path segment:
| Route | Method | Behavior |
|---|---|---|
/api/siwx/nonce | GET POST | Issues a nonce, stores it for 300 seconds, returns { nonce } |
/api/siwx/verify | POST | Verifies { message, signature } (max 64 KB), consumes the nonce, creates the session, sets the cookie, returns the session JSON |
/api/siwx/session | GET | Returns the session of the cookie, or null |
/api/siwx/session | DELETE | Revokes the session and clears the cookie |
/api/siwx/logout | POST | Same as DELETE /api/siwx/session |
POST /verify responds with 400 for a malformed body, 401 when verification or the nonce check fails, and 413 for a body over 64 KB. On the client, fetch the nonce from /api/siwx/nonce (the getNonce option of useSiwx in @tuwaio/siwx-react): the handler only accepts nonces it has issued.
3. Reading the session on the server
// app/actions/updateProfile.ts
'use server';
import { getSiwxServerSession, isSessionMatchingTarget } from '@tuwaio/siwx-server';
import { cookies } from 'next/headers';
import { sessionStore } from '@/lib/authStores';
export async function updateProfile(address: string) {
const session = await getSiwxServerSession({ cookieSource: await cookies(), sessionStore });
if (!session || !isSessionMatchingTarget(session, address)) {
throw new Error('Unauthorized');
}
// The request is made by the owner of `address`.
}cookieSource also accepts a Request, a Headers object or a Cookie header string, so the same call works in any framework.
4. Stateless demo profile
// app/api/siwx/[...siwx]/route.ts
import { createStatelessDemoSiwxHandler } from '@tuwaio/siwx-server/next';
const signingSecret = process.env.SIWX_DEMO_SIGNING_SECRET; // server-only, at least 32 characters
if (!signingSecret) throw new Error('SIWX_DEMO_SIGNING_SECRET is not set');
export const { GET, POST, DELETE } = createStatelessDemoSiwxHandler({
signingSecret,
policy: {
expectedDomain: 'demo.tuwa.io',
requireExpirationTime: true,
maxIssuedAtAgeSeconds: 300,
maxSessionLifetimeSeconds: 30 * 60,
},
});The routes are the same as in the durable profile, without a database:
/noncereturns a nonce signed withsigningSecretand valid for 300 seconds./verifyaccepts only such nonces, and only once: used nonces are remembered in the memory of the server instance until they expire. As with the durable profile, the client must fetch the nonce (getNonceinuseSiwx).- The session is an HMAC-SHA256 signed token in the cookie. It expires with the message
expirationTime(or afterttlSeconds, 30 minutes by default). WithmaxSessionLifetimeSeconds: 1800, setfields.expirationTimeon the client to at most 30 minutes:useSiwxdefaults to 24 hours.
[!WARNING] With several server instances (or serverless functions), each one remembers its own used nonces, so a signed message can be replayed on another instance within the 300-second nonce lifetime. A token stays valid until it expires, even after logout, and it is signed, not encrypted. Use the demo profile only for demos and prototypes, with a short
maxSessionLifetimeSeconds.
5. Other frameworks
import {
createSessionCookie,
type SiwxNonceStore,
type SiwxSessionStore,
toSession,
verifySiwxPayload,
} from '@tuwaio/siwx-server';
declare const nonceStore: SiwxNonceStore;
declare const sessionStore: SiwxSessionStore;
export async function handleVerify(request: Request): Promise<Response> {
const { message, signature } = (await request.json()) as { message: string; signature: string };
const result = await verifySiwxPayload(
{ message, signature },
{ policy: { expectedDomain: 'app.tuwa.io', requireExpirationTime: true } },
);
if (!result.success || !result.data) return Response.json({ error: result.error }, { status: 401 });
// Accept only nonces issued by your nonce endpoint, once.
if (!(await nonceStore.consume({ nonce: result.data.nonce }))) {
return Response.json({ error: 'Invalid nonce' }, { status: 401 });
}
const record = await sessionStore.create({ session: toSession(result.data), ttlSeconds: 7 * 24 * 60 * 60 });
return Response.json(record.session, { headers: { 'Set-Cookie': createSessionCookie(record.id) } });
}🛡️ Security Notes
- Nonce: a signature proves ownership only if its nonce is fresh. Both handlers accept a nonce only if it was issued by
/noncein the last 300 seconds, and only once. The durable handler consumes it in yourSiwxNonceStore, whoseconsumemust be atomic and shared by every instance; the demo handler checks the nonce signature and remembers used nonces per instance.verifySiwxPayloadalone checks nothing but the optionalusedNoncesset. - Domain and URI: without
policy.expectedDomain, a message signed for another site is accepted. Always setexpectedDomain(case-insensitive exact match) and, if possible,expectedUri(same origin or a sub-path). - Expiry: messages are rejected once their
expirationTimehas passed, with the sameclockSkewSecondstolerance as the other timing rules (60 seconds by default; set0for none). SetrequireExpirationTime,maxSessionLifetimeSecondsandmaxIssuedAtAgeSeconds; the last one has no default, so stale messages are accepted until you set it. Durable sessions expire through the store:SiwxSessionStore.getmust returnnullfor expired records. - Chains:
allowedChainIdsentries are full CAIP-2 IDs matched exactly, both when a message is verified and ingetSiwxServerSession, soeip155:1never allowssolana:1. - Server-side checks:
getSiwxServerSessionapplies onlyexpectedDomainandallowedChainIdsof itspolicy, plusrequireExpirationTimefor durable sessions and the token expiry for demo sessions. The full policy runs when the message is verified. - Cookies: the session cookie is always
HttpOnly, andSecureandSameSite=Strictby default. Keepsecure: truein production. - Secrets: the demo
signingSecretmust be at least 32 characters and must never reach the browser. Changing it invalidates every demo session.
🌐 External Services
The package contacts no hosts of its own. With verifyOptions.publicClient (or publicClient in verifySiwxPayload), the EIP-1271 fallback sends one eth_call to the RPC endpoint of that client. Sessions and nonces go only to the stores you provide.
📚 API Reference
Every export, with signatures and types generated from the source, is documented at siwx.docs.tuwa.io/packages/siwx-server : the server module for @tuwaio/siwx-server and the next module for @tuwaio/siwx-server/next.
📄 License
Licensed under the Apache-2.0 License. See the LICENSE file for details.