skills-ws
Version:
87 agent skills for AI coding assistants — marketing, growth, web3, dev, design & operations. Built for OpenClaw, Claude Code, Cursor, and Codex.
215 lines (175 loc) • 6.32 kB
Markdown
## Contents
- 4. initData HMAC Validation <a name="initdata-validation"></a>
- How It Works
- Complete Validation Implementation
- API Route Middleware
- Usage in API Route
## 4. initData HMAC Validation <a name="initdata-validation"></a>
**This is critical for security.** The initData string is signed by Telegram using HMAC-SHA256. Your backend MUST validate it before trusting any user data.
### How It Works
1. Telegram creates a data string from initData fields (sorted alphabetically, excluding `hash`)
2. A secret key is derived: `HMAC-SHA256("WebAppData", bot_token)` — key is `"WebAppData"`, data is `bot_token`
3. The signature is: `HMAC-SHA256(secret_key, data_check_string)` — key is `secret_key`, data is `data_check_string`
4. You compare this against the `hash` field
### Complete Validation Implementation
```ts
// src/lib/validate-init-data.ts
import { createHmac, timingSafeEqual as cryptoTimingSafeEqual } from "node:crypto";
interface TelegramUser {
id: number;
first_name: string;
last_name?: string;
username?: string;
language_code?: string;
is_premium?: boolean;
photo_url?: string;
}
interface InitData {
query_id?: string;
user?: TelegramUser;
auth_date: number;
hash: string;
start_param?: string;
chat_type?: string;
chat_instance?: string;
}
interface ValidationResult {
valid: boolean;
data?: InitData;
error?: string;
}
const MAX_AGE_SECONDS = 86400; // 24 hours — adjust for your needs
/**
* Validate Telegram Mini App initData using HMAC-SHA256.
*
* @param initDataRaw - The raw query string from the client
* @param botToken - Your bot token from @BotFather
* @returns - Validation result with parsed data
*/
export function validateInitData(
initDataRaw: string,
botToken: string
): ValidationResult {
if (!initDataRaw || !botToken) {
return { valid: false, error: "Missing initData or bot token" };
}
// Parse the query string
const params = new URLSearchParams(initDataRaw);
const hash = params.get("hash");
if (!hash) {
return { valid: false, error: "Missing hash in initData" };
}
// Validate hash is a 64-character hex string before passing to Buffer.from.
// Invalid hex silently produces a shorter buffer, causing timingSafeEqual to throw.
if (!/^[0-9a-f]{64}$/i.test(hash)) {
return { valid: false, error: "Invalid hash format in initData" };
}
// Build the data-check-string:
// 1. Remove the hash parameter
// 2. Sort remaining params alphabetically by key
// 3. Join as "key=value" with newlines
params.delete("hash");
const dataCheckString = Array.from(params.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => `${key}=${value}`)
.join("\n");
// Derive the secret key: HMAC-SHA256("WebAppData", bot_token)
const secretKey = createHmac("sha256", "WebAppData")
.update(botToken)
.digest();
// Compute the expected hash
const computedHash = createHmac("sha256", secretKey)
.update(dataCheckString)
.digest("hex");
// Timing-safe comparison to prevent timing attacks
// Both are hex strings — use "hex" encoding so Buffer lengths match
// the byte count (32 bytes) rather than the string length (64 chars).
// Using "utf-8" works too since hex is ASCII-safe, but "hex" is semantically correct.
const computedBuf = Buffer.from(computedHash, "hex");
const hashBuf = Buffer.from(hash, "hex");
if (computedBuf.length !== hashBuf.length || !cryptoTimingSafeEqual(computedBuf, hashBuf)) {
return { valid: false, error: "Invalid hash — signature mismatch" };
}
// Check auth_date freshness
const authDateStr = params.get("auth_date");
if (!authDateStr) {
return { valid: false, error: "Missing auth_date in initData" };
}
const authDate = parseInt(authDateStr, 10);
const now = Math.floor(Date.now() / 1000);
if (now - authDate > MAX_AGE_SECONDS) {
return { valid: false, error: "initData expired" };
}
// Parse user data
const userStr = params.get("user");
let user: TelegramUser | undefined;
if (userStr) {
try {
user = JSON.parse(userStr);
} catch {
return { valid: false, error: "Invalid user JSON in initData" };
}
}
return {
valid: true,
data: {
query_id: params.get("query_id") || undefined,
user,
auth_date: authDate,
hash,
start_param: params.get("start_param") || undefined,
chat_type: params.get("chat_type") || undefined,
chat_instance: params.get("chat_instance") || undefined,
},
};
}
```
### API Route Middleware
```ts
// src/lib/auth-middleware.ts
import { validateInitData } from "./validate-init-data";
import { NextRequest, NextResponse } from "next/server";
const BOT_TOKEN = process.env.BOT_TOKEN!;
// In development we validate against the throwaway DEV_BOT_TOKEN, which is the
// token the client used to SIGN the mock initData (see "Development Without
// Telegram"). This keeps the dev flow end-to-end without a validation bypass.
const VALIDATION_TOKEN =
process.env.NODE_ENV !== "production" && process.env.DEV_BOT_TOKEN
? process.env.DEV_BOT_TOKEN
: BOT_TOKEN;
export function withTelegramAuth(
handler: (req: NextRequest, userId: number) => Promise<NextResponse>
) {
return async (req: NextRequest) => {
const authHeader = req.headers.get("authorization") || "";
if (!authHeader.startsWith("tma ")) {
return NextResponse.json(
{ error: "Missing Telegram authorization" },
{ status: 401 }
);
}
const initDataRaw = authHeader.slice(4); // strip "tma "
const result = validateInitData(initDataRaw, VALIDATION_TOKEN);
if (!result.valid || !result.data?.user) {
return NextResponse.json(
{ error: result.error || "Invalid initData" },
{ status: 401 }
);
}
return handler(req, result.data.user.id);
};
}
```
### Usage in API Route
```ts
// src/app/api/profile/route.ts
import { NextRequest, NextResponse } from "next/server";
import { withTelegramAuth } from "@/lib/auth-middleware";
export const POST = withTelegramAuth(async (req, userId) => {
// userId is guaranteed valid at this point
const body = await req.json();
// ... your logic here
return NextResponse.json({ ok: true, userId });
});
```
---