A successful <code className="text-saffron font-mono text-sm">otp/verify</code> call — whether the code arrived over WhatsApp or SMS, MiniMoth's delivery channel doesn't affect sessions at all — returns two tokens. Here's what they do and why both exist.

<div className="mt-6 grid sm:grid-cols-2 gap-4">
  <div className="border border-white/10 rounded-lg p-4">
    <p className="text-sm font-medium text-ink mb-1">access_token</p>
    <p className="text-xs text-ink/50 leading-relaxed">Short-lived — expires in <b className="text-ink/70">5 minutes</b>. Send this with every authenticated request to your backend. Validate it locally as a JWT (zero network round trip — the Node.js SDK does this by default), or call <code className="font-mono text-saffron">session/validate</code> for a server-checked result. See <a href="#validation-methods" className="text-saffron hover:underline">local vs. API validation</a> for the trade-off.</p>
  </div>
  <div className="border border-white/10 rounded-lg p-4">
    <p className="text-sm font-medium text-ink mb-1">refresh_token</p>
    <p className="text-xs text-ink/50 leading-relaxed">Long-lived — <b className="text-ink/70">absolute expiry</b> set per project (default 10 days, max 30 days). Store this securely. Use it to get a new access_token when the old one expires, without asking the user to re-enter their OTP. Every refresh also rotates the refresh_token itself — replace both, see <a href="#refresh-a-session" className="text-saffron hover:underline">Refresh a session</a>.</p>
  </div>
</div>

<div className="mt-10">
<Section label="Why two tokens?">
  <p>The access_token is intentionally short-lived. If it leaks — intercepted in a log, accidentally exposed in an error — the damage is limited to 5 minutes. After that it's useless.</p>
  <p>But you can't ask your user to re-verify their phone number every 5 minutes. That's where the refresh_token comes in. It lives on your server only, never travels in API responses to end users, and silently exchanges for a new access_token in the background.</p>
  <p className="text-ink/50">Think of it like this: the access_token is the door key (short-lived, used constantly), and the refresh_token is the key-cutting machine (long-lived, stored safely, used only when the door key expires).</p>
</Section>
</div>

<div className="mt-8">
<Section label="Token lifecycle">
  <FlowSteps steps={[
    { marker: '1', title: 'OTP verified → two tokens issued', description: 'Store <code class="font-mono">access_token</code> in memory (or a short-lived cookie). Store <code class="font-mono">refresh_token</code> in an httpOnly cookie or secure server-side store.' },
    { marker: '2', title: 'Every request → validate access_token', description: 'Call <code class="font-mono">session/validate</code>. Returns <code class="font-mono">valid: true</code> + the session\'s expiry. Fast and lightweight — safe to call on every request. To resolve which end-user this session belongs to, look up the <code class="font-mono">session_id</code> against your own records — see the note below.' },
    { marker: '3', title: 'access_token expires (5 min) → call refresh', description: 'Call <code class="font-mono">session/refresh</code> with the refresh_token. Both tokens are rotated — you get a new access_token <em>and</em> a new refresh_token. Replace both in storage.' },
    { marker: '4', title: 'refresh_token reaches absolute expiry', description: 'The session is over. The user must re-verify their phone to get new tokens. The absolute expiry never extends — using the refresh token does not reset the clock.' },
    { marker: '✕', title: 'User logs out → both tokens revoked immediately', description: 'Call <code class="font-mono">session/logout</code>. The access_token is immediately invalidated and all refresh tokens for the session are removed.', tone: 'final' },
  ]} />
</Section>
</div>

<div className="mt-8">
<Section label="The 10-second grace window">
  <p>Every refresh rotates both tokens. The old refresh_token is immediately invalid. But there's one common scenario where this causes problems without a grace window:</p>
  <div className="bg-white/5 border border-white/10 rounded-lg px-4 py-3 text-xs text-ink/60 leading-relaxed my-2">
    <p className="text-ink/80 font-medium mb-1">The SSR hydration problem</p>
    <p>In Next.js or similar frameworks, the server renders the page and the browser hydrates it almost simultaneously. If both try to refresh the token at the same instant — within milliseconds of each other — the first call succeeds and rotates the token. The second call arrives a few milliseconds later with the now-invalidated old token and gets a 401, logging the user out unexpectedly.</p>
  </div>
  <p>The 10-second grace window solves this: if the same refresh_token is used a second time within 10 seconds, MiniMoth treats it as a duplicate of the first call and returns a new access_token without invalidating anything.</p>
  <p>After 10 seconds, the grace window closes. If an already-rotated refresh_token is used again, MiniMoth treats it as a <b className="text-ink">stolen token</b> and immediately revokes all tokens for that session, forcing the user to re-verify.</p>
  <div className="mt-4">
    <CompareGrid items={[
      { title: 'Within 10 seconds', description: 'Same token used again → treated as a duplicate. Returns new access_token safely. No lockout.' },
      { title: 'After 10 seconds', description: 'Same token used again → theft detected. All session tokens revoked. User must re-verify.', accent: true },
    ]} />
  </div>
  <p className="text-xs text-ink/40 mt-3">Note: when theft is detected, any access_token already issued for that session remains valid until it expires naturally (up to 5 minutes). Revocation applies to refresh tokens immediately — access tokens expire on their own TTL.</p>
</Section>
</div>

## Validate a session

Call this on every authenticated request from your app's users. Fast and lightweight — safe to call on every request without latency concerns. Returns `{ valid, expires_at }`, plus `identity_id` when there is one to report — no `session_id` or phone number; see the note below on resolving those. See [Identity](/docs/identity) for what `identity_id` is and when it's absent.

```javascript
const res = await fetch('https://api.minimoth.dev/v1/session/validate', {
  method: 'POST',
  headers: { 'X-Api-Key': 'mm_live_...', 'Content-Type': 'application/json' },
  body: JSON.stringify({ access_token }),
})
const { valid, expires_at, identity_id } = await res.json()
// valid: false on any invalid/expired token — never throws
// identity_id is omitted entirely (not null) when there is none to report
```

<div className="mt-6 scroll-mt-8" id="validation-methods">
<Section label="API validation vs. local JWT validation">
  <p>The <code className="font-mono text-xs">access_token</code> is a signed JWT (RS256). Calling <code className="font-mono text-xs">session/validate</code> as above is <b className="text-ink">API validation</b> — every call checks MiniMoth's server-side cache, so revocation (logout, theft detection) is caught immediately.</p>
  <p>The <a href="/docs/sdk-nodejs" className="text-saffron hover:underline">Node.js SDK</a> also offers <b className="text-ink">local JWT validation</b>: it fetches MiniMoth's public signing key once from <code className="font-mono text-xs">/.well-known/jwks.json</code> (cached 1 hour) and verifies the token's signature and expiry entirely on your server — zero network round trip per request. This is the SDK's default <code className="font-mono text-xs">validateMode: 'instant'</code>.</p>
  <div className="mt-2">
    <CompareGrid items={[
      { title: 'Local (instant)', description: 'Fastest — no network call. Trade-off: blind to server-side revocation until the token\'s own 5-minute expiry. Fine for most apps.', accent: true },
      { title: 'API (or strict mode)', description: 'Always current — catches logout and theft detection immediately. Costs one network round trip per validate call.' },
    ]} />
  </div>
  <p className="text-xs text-ink/40">The SDK also has <code className="font-mono text-xs">recheck_1m</code> / <code className="font-mono text-xs">recheck_3m</code> modes that balance the two — see <a href="/docs/sdk-nodejs#validatemode" className="text-saffron hover:underline">validateMode</a> for the full comparison.</p>
</Section>
</div>

<div className="mt-6">
  <Callout variant="accent">
    <p><span className="text-saffron font-medium">Using the Node.js SDK?</span> <code className="font-mono text-xs">mm.session.safeValidate()</code> does local JWT validation by default and auto-refreshes expired tokens for you:</p>
  </Callout>
</div>
```typescript
import { MiniMoth } from '@minimoth/sdk-node'

const mm = new MiniMoth({ apiKey: process.env.MINIMOTH_API_KEY })

// Local JWT verify only, zero network round trip (default validateMode: 'instant')
const result = await mm.session.safeValidate(accessToken)
if (!result.valid) return res.status(401).json({ error: result.code })
// result.session.sessionId, result.session.expiresAt

// If the access token had expired, safeValidate already refreshed it —
// forward result.session.newTokens to the client if present
```

<h2 id="refresh-a-session" className="font-medium text-xl mt-8 mb-3 scroll-mt-8">Refresh a session</h2>

```javascript
const res = await fetch('https://api.minimoth.dev/v1/session/refresh', {
  method: 'POST',
  headers: { 'X-Api-Key': 'mm_live_...', 'Content-Type': 'application/json' },
  body: JSON.stringify({ refresh_token }),
})
const { access_token, refresh_token: new_refresh_token, expires_at } = await res.json()
// Always replace the stored refresh_token with new_refresh_token.
// The old one is immediately invalidated.
// 401 if the refresh token is expired or was reused outside the 10-second grace window.
```

**Always replace both tokens** after a refresh — store the new `access_token` and the new `refresh_token`. The old refresh_token is immediately invalid.

## Log out

```javascript
await fetch('https://api.minimoth.dev/v1/session/logout', {
  method: 'POST',
  headers: { 'X-Api-Key': 'mm_live_...', 'Content-Type': 'application/json' },
  body: JSON.stringify({ access_token }),
})
// 401 INVALID_ACCESS_TOKEN if the access_token is expired — refresh first, then log out
```

<div className="mt-8">
  <Callout>
    <p><span className="text-ink font-medium">Don't need session management?</span> You can use MiniMoth purely to verify a phone number and manage sessions yourself. See the <a href="/docs/recipes#otp-only" className="text-saffron hover:underline">OTP-only recipe</a>.</p>
    <p className="mt-2"><span className="text-ink font-medium">Resolving a session to a phone number.</span> <code className="font-mono">session/validate</code> returns only <code className="font-mono">{'{ valid, expires_at }'}</code> — no <code className="font-mono">session_id</code> and no phone number, by design (JWT payloads are unencrypted and travel client-side). The <code className="font-mono">session_id</code> lives in the <code className="font-mono">access_token</code>'s own JWT payload — decode it yourself (the Node.js SDK's <code className="font-mono">safeValidate()</code> does this for you, exposing it as <code className="font-mono">result.session.sessionId</code>). You already have the phone number at the moment you call <code className="font-mono">otp/verify</code> (you submitted it yourself); store a <code className="font-mono">session_id → phone</code> mapping in your own database at that point, and look it up whenever a validated session needs to be tied back to a specific user.</p>
  </Callout>
</div>

---

Full documentation index: https://minimoth.dev/llms.txt
