Token-Gated Authentication
About
The token-gated authentication pattern implements a Vara-native sign-in-with-wallet flow and combines it with on-chain access control. A user signs a structured message, the server verifies the signature, checks the user’s VFT balance on-chain, and then issues a JWT that downstream services can trust for a limited time.
This is the backend equivalent of taking on-chain ownership and turning it into a practical Web application session.
Why the pattern matters
Many apps want access to depend on token ownership, but they do not want to re-query the chain on every request. That would be expensive, slow, and difficult to operationalize across many downstream endpoints.
This pattern solves that by separating responsibilities:
- the chain decides whether the user currently satisfies the balance threshold,
- the auth server decides whether to issue or refresh a JWT,
- downstream services consume the JWT rather than reading the chain directly on every request.
High-level authentication flow
The server’s authentication flow is:
- issue a nonce,
- have the client sign a structured message containing nonce, domain, chain ID, and timestamps,
- consume and validate the nonce,
- verify signature correctness,
- query on-chain token balance,
- compare against the configured threshold,
- issue JWT on success.
That flow combines standard web-session mechanics with Vara-native signature verification and on-chain reads.
The implementation is split into three focused modules:
src/server.tsowns HTTP routing and auth lifecycle,src/auth.tsowns nonce, schema, signature, and JWT helpers,src/gear.tsowns address normalization and VFT balance reads.
The nonce layer
The nonce system exists to prevent replay attacks.
src/auth.ts implements:
export const nonceStore = new Map<string, number>();
export function issueNonce(ttlSec: number): string {
const nonce = randomUUID();
nonceStore.set(nonce, Date.now() + ttlSec * 1_000);
return nonce;
}
export function consumeNonce(nonce: string): boolean {
const exp = nonceStore.get(nonce);
if (!exp) return false;
nonceStore.delete(nonce);
return exp > Date.now();
}The key property is delete-on-read semantics: once a nonce is consumed, it cannot be reused even if the original signed message is intercepted.
That small property is what turns a signed message into a real login challenge instead of a replayable proof.
Verification flow in detail
The /auth/verify route is the security-critical path.
Its order of operations matters:
- validate the request body with Zod,
- extract signed fields from the message,
- consume the nonce,
- validate domain and chain ID if configured,
- validate message freshness,
- verify the wallet signature,
- query VFT balance on-chain,
- issue JWT if the threshold is met.
The route code follows that order directly:
const { address, message, signature } = SignedMessageSchema.parse(req.body);
if (!consumeNonce(nonce)) {
return sendError(res, 400, "Invalid or expired nonce");
}
if (!verifySignature(address, message, signature)) {
return sendError(res, 401, "Invalid signature");
}
const balRaw = await getVFTBalance(address, api);This order is deliberate. Cheap structural checks happen before cryptography, and cryptography happens before the chain query.
The route also enforces server-level message binding before querying the chain:
if (EXPECTED_DOMAIN && domain !== EXPECTED_DOMAIN) {
return sendError(res, 400, "Invalid domain");
}
if (EXPECTED_CHAIN_ID && chainId !== EXPECTED_CHAIN_ID) {
return sendError(res, 400, "Invalid chainId");
}That prevents the same signed payload from being accepted in the wrong application or wrong chain context when those guards are configured.
Signature verification
Signature verification uses Polkadot/Substrate cryptography:
export function verifySignature(
address: string,
message: string,
signature: string
): boolean {
const { isValid } = signatureVerify(message, signature, address);
return isValid;
}That is what makes the pattern Vara-native rather than an Ethereum-style SIWE clone.
The request schema is also validated before cryptography begins:
export const SignedMessageSchema = z.object({
address: z.string().min(3),
message: z.string().min(5),
signature: z.string().min(10),
});That keeps malformed inputs out of the expensive and security-critical parts of the flow.
On-chain entitlement check
After signature verification, the server reads token balance with a generated Sails client:
const program = new Program(api, VFT_PROGRAM_ID as HexString);
const svc = new Service(program);
const normalized = toHexAddress(accountAddress);
const result = await svc.balanceOf(normalized);That is an important detail. The server is not just using a generic storage query; it is using the program’s own client-facing API shape.
The address normalization helper is part of that flow:
export const toHexAddress = (addr: string): HexString => {
try {
if (addr?.startsWith("0x") && addr.length > 2) return addr as HexString;
const decoded = decodeAddress(addr);
return u8aToHex(decoded) as HexString;
} catch {
return addr as HexString;
}
};That lets the auth server accept both SS58-style wallet addresses and hex account IDs while still querying the VFT contract in the format it expects.
JWT issuance and refresh
On success, the server issues:
return jwt.sign({ sub: address, hasAccess: true }, secret, {
expiresIn: `${ttlMin}m`,
});This is what lets downstream services treat the auth server as the source of entitlement for a bounded time window.
The refresh path adds another useful layer. If RECHECK_ON_REFRESH is enabled, the server queries the balance again before renewing the token. That creates a soft revocation model:
- if the user still meets the threshold, refresh succeeds,
- if the user sold or transferred the tokens, refresh fails and access naturally expires.
The refresh route makes that policy explicit:
if (remain > REFRESH_MIN_REMAIN) {
return res.json({ jwt: token, remainingSec: remain, refreshed: false });
}
if (RECHECK_ON_REFRESH) {
const api = await getApi();
const balRaw = await getVFTBalance(payload.sub, api);
// compare balance against threshold again before renewing
}This is a good example of chain state being used as an input to backend auth policy rather than as the auth session itself.
Why this is a good backend pattern
This example is not just about one endpoint. It models a full auth lifecycle:
- nonce issuance,
- signature-based login,
- entitlement check,
- JWT refresh,
- optional chain revalidation on refresh.
That makes it a strong analogue to the more detailed contract articles, because it explains both the building blocks and the full operational flow.
The /entitlement route closes the loop by giving downstream services a lightweight authorization check:
app.get("/entitlement", (req, res) => {
const payload = jwt.verify(token, JWT_SECRET) as {
sub: string;
hasAccess: boolean;
};
return res.json({ ok: true, address: payload.sub, hasAccess: payload.hasAccess });
});So the backend pattern is not only about login. It is about turning on-chain ownership into reusable Web authorization.
Production guidance
- Keep nonce handling single-use and short-lived.
- Bind signed messages to expected domain and chain ID where possible.
- Re-check balance on refresh if access should track token ownership over time.
- Keep JWT TTL short enough that off-chain access cannot drift too far from on-chain reality.
- Normalize addresses before contract reads so the auth layer accepts both SS58 and hex-style inputs safely.
- Keep nonce helpers, chain-read helpers, and HTTP routes in separate modules so the auth path stays auditable.