Server Verification
Always verify Shoo id_tokens on your server before trusting identity claims
Why verify server-side?
The browser auth flow gives the user a signed id_token, but the browser is an untrusted environment. A user could fabricate or tamper with tokens stored in localStorage.
Never trust unverified client-side claims for authorization decisions.
Always verify the id_token signature, issuer, audience, and expiration on your server before granting access.
Verification with jose
Install the jose library:
bun add joseThen verify tokens using the JWKS endpoint:
import { createRemoteJWKSet, jwtVerify } from "jose";
const SHOO_BASE_URL = "https://shoo.dev";
const SHOO_ISSUER = "https://shoo.dev";
const jwks = createRemoteJWKSet(
new URL("/.well-known/jwks.json", SHOO_BASE_URL),
);
export async function verifyShooToken(idToken: string, appOrigin: string) {
const audience = `origin:${new URL(appOrigin).origin}`;
const { payload } = await jwtVerify(idToken, jwks, {
issuer: SHOO_ISSUER,
audience,
});
if (typeof payload.pairwise_sub !== "string") {
throw new Error("Shoo token missing pairwise_sub");
}
return payload;
}createRemoteJWKSet fetches and caches the public keys from Shoo's JWKS endpoint. Key rotation is handled automatically.
What to verify
Your server must check all of these:
| Check | Value | Why |
|---|---|---|
Issuer (iss) | https://shoo.dev | Ensures the token came from Shoo, not a different issuer |
Audience (aud) | origin:{your_app_origin} | Ensures the token was issued for your app, not someone else's |
Expiration (exp) | Must be in the future | Prevents replay of expired tokens |
| Signature | Valid ES256 against JWKS | Ensures the token hasn't been tampered with |
| Pairwise sub | Must be a string | The core identity claim you'll use |
The jose library checks issuer, audience, expiration, and signature automatically when you pass the options to jwtVerify. You just need to verify pairwise_sub exists.
Token claims reference
| Claim | Type | Always present | Description |
|---|---|---|---|
iss | string | Yes | Issuer (https://shoo.dev) |
aud | string | Yes | Audience (origin:{app_origin}) |
sub | string | Yes | Subject (same as pairwise_sub) |
pairwise_sub | string | Yes | Domain-scoped unique user ID |
iat | number | Yes | Issued at (unix timestamp) |
exp | number | Yes | Expires at (unix timestamp) |
jti | string | Yes | JWT ID for replay protection |
email | string | No | Only with PII consent |
email_verified | boolean | No | Only with PII consent |
name | string | No | Only with PII consent |
picture | string | No | Only with PII consent |
Full Next.js example
Here's a complete API route that accepts an id_token from the client and verifies it:
import type { NextApiRequest, NextApiResponse } from "next";
import { createRemoteJWKSet, jwtVerify } from "jose";
const SHOO_BASE_URL = process.env.SHOO_BASE_URL || "https://shoo.dev";
const SHOO_ISSUER = process.env.SHOO_ISSUER || SHOO_BASE_URL;
const APP_ORIGIN = process.env.APP_ORIGIN || "http://localhost:3000";
const jwks = createRemoteJWKSet(
new URL("/.well-known/jwks.json", SHOO_BASE_URL),
);
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
const idToken =
typeof req.body?.idToken === "string" ? req.body.idToken : "";
if (!idToken) {
return res.status(400).json({ error: "Missing idToken" });
}
try {
const audience = `origin:${new URL(APP_ORIGIN).origin}`;
const { payload } = await jwtVerify(idToken, jwks, {
issuer: SHOO_ISSUER,
audience,
});
if (typeof payload.pairwise_sub !== "string") {
return res.status(401).json({ error: "Missing pairwise_sub claim" });
}
return res.status(200).json({
userId: payload.pairwise_sub,
email: payload.email,
name: payload.name,
});
} catch (err) {
const message =
err instanceof Error ? err.message : "Verification failed";
return res.status(401).json({ error: message });
}
}On the client, send the token to this endpoint after sign-in:
const identity = auth.identity;
if (identity.token) {
const res = await fetch("/api/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ idToken: identity.token }),
});
const data = await res.json();
// data.userId is now server-verified
}JWKS and discovery endpoints
| Endpoint | URL |
|---|---|
| JWKS (public keys) | https://shoo.dev/.well-known/jwks.json |
| OpenID Configuration | https://shoo.dev/.well-known/openid-configuration |
The JWKS endpoint returns ES256 (P-256) public keys in standard JWK format. Any OIDC-compatible JWT library can use these for verification.