Recipes
Common patterns for integrating MiniMoth into real apps.
OTP-only delivery
Use MiniMoth purely to verify a phone number. You handle sessions yourself — with your own JWT, your own database, or whatever auth system you already have. MiniMoth acts as the OTP delivery and verification layer only.
This is a good fit if you already have users in a database and just want to add phone verification, or if you need session behaviour that MiniMoth doesn't support (e.g. multi-device, custom claims in tokens).
// Step 1: send the OTP
await fetch('https://api.minimoth.dev/v1/otp/send', {
method: 'POST',
headers: { 'X-Api-Key': 'mm_live_...', 'Content-Type': 'application/json' },
body: JSON.stringify({ phone: '+919876543210' }),
})
// Step 2: verify the code
const res = await fetch('https://api.minimoth.dev/v1/otp/verify', {
method: 'POST',
headers: { 'X-Api-Key': 'mm_live_...', 'Content-Type': 'application/json' },
body: JSON.stringify({ phone: '+919876543210', code: '123456' }),
})
if (!res.ok) {
// wrong code, too many attempts, or OTP expired
throw new Error('OTP verification failed')
}
const { access_token } = await res.json()
// access_token proves the phone was verified — now use your own auth:
// - create a row in your sessions table
// - set a cookie
// MiniMoth's access_token and refresh_token are optional — ignore them if you prefer Protecting an API route
Verify the access_token locally as a JWT in your middleware or route guard — no network round trip per request. The guard's job is just confirming the session is valid and exposing session_id; resolving that to an end-user (your own session_id → phone mapping, built at otp/verify time) is your app's concern, not the guard's.
// Middleware / route guard (Node.js / Express example)
// Verifies the access_token locally as a JWT — no network round trip per
// request. Fetch and cache MiniMoth's public signing keys once, refetching
// on a 'kid' cache miss rather than on every request.
const jwt = require('jsonwebtoken')
const { createPublicKey } = require('crypto')
let jwks = null
async function getPublicKey(kid) {
if (!jwks?.keys.some(k => k.kid === kid)) {
const res = await fetch('https://api.minimoth.dev/.well-known/jwks.json')
jwks = await res.json()
}
const jwk = jwks.keys.find(k => k.kid === kid)
return createPublicKey({ key: jwk, format: 'jwk' })
}
async function requireAuth(req, res, next) {
const token = req.headers['x-access-token'] ?? req.cookies.access_token
if (!token) return res.status(401).json({ error: 'No token' })
try {
const { kid } = jwt.decode(token, { complete: true }).header
const publicKey = await getPublicKey(kid)
const payload = jwt.verify(token, publicKey, { algorithms: ['RS256'] })
req.sessionId = payload.session_id
next()
} catch {
res.status(401).json({ error: 'Invalid or expired session' })
}
} session/validate instead — see local vs. API validation.
The Node.js SDK handles this same JWKS fetch, cache, and local verify for you via mm.session.safeValidate() — no need to write it yourself. It also lets you dial revocation freshness up or down per call via validateMode: instant (local only, the default), recheck_1m / recheck_3m (revocation check, cached), or strict (revocation check every call). See validateMode.
Handling token refresh
The access_token expires after 5 minutes. The pattern below retries any failed request automatically after refreshing — the user never sees a logout or an error.
Security note: keep the refresh_token on your server only, in an httpOnly cookie. Never expose it to JavaScript in the browser. The client only ever sees and stores the short-lived access_token.
// Client-side fetch helper (works with any framework)
// Store access_token in memory, refresh_token in an httpOnly cookie (set by your server)
let accessToken = null
async function apiFetch(url, options = {}) {
const res = await fetch(url, {
...options,
headers: { ...options.headers, 'Authorization': `Bearer ${accessToken}` },
})
// Server auto-refreshed the token (SDK detected expiry in middleware) — update local copy.
// Must happen on every response so the next request uses the new token, not the old one.
const newToken = res.headers.get('X-New-Access-Token')
if (newToken) accessToken = newToken
// 401 means the access token is expired and the server could not auto-refresh
// (e.g. no refresh token in store). Fall back to calling the refresh endpoint directly.
if (res.status === 401) {
const refreshed = await fetch('/auth/refresh', { method: 'POST' })
if (!refreshed.ok) {
window.location.href = '/login'
return
}
const { access_token } = await refreshed.json()
accessToken = access_token
return fetch(url, {
...options,
headers: { ...options.headers, 'Authorization': `Bearer ${accessToken}` },
})
}
return res
}
// Your server's /auth/refresh endpoint calls MiniMoth:
// POST /v1/session/refresh with the refresh_token from the httpOnly cookie
// Returns new access_token + new refresh_token (set new cookie, return new access_token) Next.js App Router — refresh endpoint
A full server-side refresh route for Next.js. The refresh_token lives in an httpOnly cookie managed by the server; the browser only receives the new short-lived access_token.
// app/api/auth/refresh/route.ts (Next.js App Router)
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'
export async function POST() {
const cookieStore = cookies()
const refreshToken = cookieStore.get('mm_refresh')?.value
if (!refreshToken) {
return NextResponse.json({ error: 'No refresh token' }, { status: 401 })
}
const res = await fetch('https://api.minimoth.dev/v1/session/refresh', {
method: 'POST',
headers: { 'X-Api-Key': process.env.MINIMOTH_API_KEY!, 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken }),
})
if (!res.ok) {
// Refresh token expired or revoked
const response = NextResponse.json({ error: 'Session expired' }, { status: 401 })
response.cookies.delete('mm_refresh')
return response
}
const { access_token, refresh_token: newRefreshToken } = await res.json()
const response = NextResponse.json({ access_token })
response.cookies.set('mm_refresh', newRefreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
path: '/api/auth/refresh',
maxAge: 60 * 60 * 24 * 10, // match your project's refresh window
})
return response
} path: '/api/auth/refresh' on the cookie so the browser only sends it to that specific endpoint — not to every API route.
With the Node.js SDK
The same patterns above, using @minimoth/sdk-node. The SDK handles phone normalisation, typed errors, and auto-refreshes expired tokens inside safeValidate() — no refresh boilerplate needed. Full SDK guide →
Express — full auth flow
Send, verify, protect routes, and logout — all using the SDK. Auto-refresh is built into safeValidate(); new tokens arrive in session.newTokens when rotation happens.
import express from 'express'
import cookieParser from 'cookie-parser'
import { MiniMoth, MiniMothError } from '@minimoth/sdk-node'
const app = express()
app.use(express.json())
app.use(cookieParser())
const mm = new MiniMoth({ apiKey: process.env.MINIMOTH_API_KEY })
app.post('/auth/send', async (req, res) => {
try {
const { otpId } = await mm.otp.send({ phone: req.body.phone })
res.json({ otpId })
} catch (err) {
if (err instanceof MiniMothError) {
return res.status(err.statusCode || 400).json({ error: err.code })
}
throw err
}
})
app.post('/auth/verify', async (req, res) => {
const result = await mm.otp.verify({ phone: req.body.phone, otp: req.body.otp })
if (!result.valid) return res.status(400).json({ error: result.code })
res.cookie('mm_refresh', result.refreshToken, { httpOnly: true, secure: true, sameSite: 'strict' })
res.json({ accessToken: result.accessToken })
})
// safeValidate auto-refreshes expired tokens — no manual refresh logic
async function requireAuth(req, res, next) {
const token = req.headers['x-access-token']
if (!token) return res.status(401).json({ error: 'No token' })
const result = await mm.session.safeValidate(token)
if (!result.valid) return res.status(401).json({ error: result.code })
req.sessionId = result.session.sessionId
req.phone = await lookupPhoneBySessionId(req.sessionId) // your own session_id -> phone mapping, stored at verify time
if (result.session.newTokens) {
res.setHeader('X-New-Access-Token', result.session.newTokens.accessToken)
res.cookie('mm_refresh', result.session.newTokens.refreshToken, { httpOnly: true, secure: true, sameSite: 'strict' })
}
next()
}
app.get('/profile', requireAuth, (req, res) => {
res.json({ phone: req.phone })
})
app.post('/auth/logout', requireAuth, async (req, res) => {
await mm.session.logout({
accessToken: req.headers['x-access-token'],
refreshToken: req.cookies.mm_refresh,
})
res.clearCookie('mm_refresh')
res.json({ ok: true })
}) Next.js App Router — full auth flow
Five route handlers and a middleware file. The SDK replaces all raw fetch calls to MiniMoth — send, verify, refresh, validate, and logout.
// lib/minimoth.ts
import { MiniMoth } from '@minimoth/sdk-node'
export const mm = new MiniMoth({ apiKey: process.env.MINIMOTH_API_KEY! })
// app/api/auth/send/route.ts
import { mm } from '@/lib/minimoth'
import { MiniMothError } from '@minimoth/sdk-node'
import { NextRequest, NextResponse } from 'next/server'
export async function POST(req: NextRequest) {
const { phone } = await req.json()
try {
const { otpId } = await mm.otp.send({ phone })
return NextResponse.json({ otpId })
} catch (err) {
if (err instanceof MiniMothError) {
return NextResponse.json({ error: err.code }, { status: err.statusCode || 400 })
}
throw err
}
}
// app/api/auth/verify/route.ts
export async function POST(req: NextRequest) {
const { phone, otp } = await req.json()
const result = await mm.otp.verify({ phone, otp })
if (!result.valid) return NextResponse.json({ error: result.code }, { status: 400 })
const response = NextResponse.json({ accessToken: result.accessToken })
response.cookies.set('mm_refresh', result.refreshToken, {
httpOnly: true, secure: true, sameSite: 'strict', path: '/', maxAge: 60 * 60 * 24 * 10,
})
return response
}
// app/api/auth/refresh/route.ts — SDK replaces the raw fetch call
import { cookies } from 'next/headers'
export async function POST() {
const refreshToken = cookies().get('mm_refresh')?.value
if (!refreshToken) return NextResponse.json({ error: 'No refresh token' }, { status: 401 })
try {
const tokens = await mm.session.refresh(refreshToken)
const response = NextResponse.json({ accessToken: tokens.accessToken })
response.cookies.set('mm_refresh', tokens.refreshToken, {
httpOnly: true, secure: true, sameSite: 'strict', path: '/', maxAge: 60 * 60 * 24 * 10,
})
return response
} catch {
const response = NextResponse.json({ error: 'SESSION_EXPIRED' }, { status: 401 })
response.cookies.delete('mm_refresh')
return response
}
}
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
export async function middleware(req: NextRequest) {
const accessToken = req.cookies.get('mm_access')?.value
if (!accessToken) return NextResponse.redirect(new URL('/login', req.url))
const result = await mm.session.safeValidate(accessToken)
if (!result.valid) return NextResponse.redirect(new URL('/login', req.url))
const response = NextResponse.next()
const phone = await lookupPhoneBySessionId(result.session.sessionId) // your own session_id -> phone mapping, stored at verify time
response.headers.set('x-phone', phone)
return response
}
export const config = { matcher: ['/dashboard/:path*'] } Need a different recipe?
Email [email protected] and we'll add it here.
Try the full OTP flow interactively in the sandbox — no SMS sent, no credits consumed. The same Playground also has a live test to send a real OTP to your phone (uses your credits).
Test in Playground →