Limited registrations open — claim your spot
minimoth

Recipes

Common patterns for integrating MiniMoth into real apps.

Recipe 1

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
What you get: a confirmed phone number. What you own: everything after that — sessions, cookies, JWTs, logout.
Recipe 2

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' })
  }
}
Local verification is blind to server-side revocation (logout, theft detection) until the token's own 5-minute expiry. If every request needs the latest revocation state, call 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.

Recipe 3

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)
Recipe 4

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
}
Set 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 →

SDK Recipe 1

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 })
})
SDK Recipe 2

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*'] }
For multi-replica or serverless deployments, provide a Redis-backed session store so the SDK can find refresh tokens across instances. See the custom session store section.

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 →