
Remix V2 Meta Sessions Review
- 28 installs
- 74 repo stars
- Updated July 21, 2026
- existential-birds/beagle
Helps with ai & agent building tasks.
About
remix-v2-meta-sessions-review is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- remix-v2-meta-sessions-review
- AI & Agent Building
- AI-coding skill
Remix V2 Meta Sessions Review by the numbers
- 28 all-time installs (skills.sh)
- Ranked #9,505 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/existential-birds/beagle --skill remix-v2-meta-sessions-reviewAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 28 |
|---|---|
| repo stars | ★ 74 |
| Last updated | July 21, 2026 |
| Repository | existential-birds/beagle ↗ |
What it does
Helps with ai & agent building tasks.
Files
Remix v2 Meta, Sessions, Auth, and CSRF Code Review
Reviews Remix v2 meta/SEO, session, auth-gate, and CSRF code paths. Loaded by the umbrella review-remix-v2 reviewer when a diff touches any of: meta/links exports, root.tsx, *.server.ts session/cookie modules, loaders/actions reading or writing session, or <Form>/useFetcher mutations.
See remix-v2-meta-sessions for canonical patterns.
Quick Reference
| Issue Type | Reference |
|---|---|
`meta` returning v1 object shape (BREAKING), OG shorthand, document.title in effect, missing <Meta />/<Links />, parent merge | references/meta-v2-shape.md |
Missing httpOnly/secure, hardcoded secrets, single-string secrets, replace-not-prepend rotation | references/cookie-security.md |
Auth check in component, logout in loader, missing commitSession, flash without commit | references/auth-gates.md |
Manual fetch POST bypassing CSRF, token in session cookie, no CSRF protection, shared secrets | references/csrf.md |
Highest-stakes detection — call out first: v1 meta object shape (return { title, description }) in a v2 codebase. It typechecks, but the runtime ignores it and the page renders with no title and no meta tags. Grep every export const meta and confirm the return value starts with [, not {.
Review Checklist
- [ ]
metareturnsMetaDescriptor[](array starts with[), NOT the v1 object shape - [ ] OG / Twitter tags use
{ property, content }, NOT v1 shorthand{ "og:title": "..." } - [ ] No
document.title = "..."oruseEffect(() => { document.title = ... })— meta is set via themetaexport - [ ]
root.tsxincludes<Meta />and<Links />inside<head> - [ ] Child
metathat wants parent values usesmatches.flatMap((m) => m.meta ?? []) - [ ]
metanull-guardsdata(loader may not have run / returnedundefinedon 404) - [ ] Cookie config sets
httpOnly: trueandsecure: process.env.NODE_ENV === "production" - [ ]
secretsis read fromprocess.env(no hardcoded strings, no committed.env.examplevalues) - [ ]
secretsis an array supporting rotation (prepend new, keep old) — not a single value - [ ] Every
session.set/session.unset/session.flashis followed by a response with"Set-Cookie": await commitSession(session) - [ ] Auth gate is in
loader(oraction) viarequireUserId(request)— NOT a component-level redirect - [ ] Logout is an
action(POST), not aloader(GET) - [ ] Mutating actions call
csrf.validate(request)when CSRF protection is in use - [ ] CSRF token uses a dedicated
createCookie("csrf", ...), NOT the session cookie - [ ] Mutations use
<Form>/useFetchersoAuthenticityTokenInputattaches the token (no manualfetchPOST)
Valid Patterns (Do NOT Flag)
These are correct usage — do not report as issues:
- `sameSite: "lax"` — acceptable default. Not every app needs
"strict"; flag only when threat model warrants stricter (e.g. CSRF protection is otherwise absent). - `meta` returning `[]` — legitimate when the route intentionally emits no meta (inherits root tags or relies on a sibling).
- `links` returning `[]` — legitimate when the route has no route-specific stylesheets or preloads.
- `session.flash(...)` followed on the next line by `commitSession(session)` — the standard 2-line flash pattern. The separation is correct; do not flag it as "missing commit".
- Auth check in `action` (not `loader`) — correct for POST-only routes (e.g. logout, delete). Loaders gate GETs; actions gate mutations.
- `charset` and `viewport` as plain JSX `<meta>` in
root.tsx's<head>— preferred over themetaexport to avoid duplicate-tag warnings under v2's no-merge behavior. - `secrets: [process.env.X!, process.env.X_OLD!]` —
!non-null assertion is acceptable when a fail-fast guard above (if (!process.env.X) throw) is present. - `throw redirect(...)` inside a loader/action — canonical Remix pattern; the thrown response is intentional.
- `commitSession` called in a loader (not just an action) — required when a loader reads a flash message and must clear it.
Context-Sensitive Rules
Only flag these issues when the specific context applies:
| Issue | Flag ONLY IF |
|---|---|
| Missing CSRF validation in action | App declares remix-utils/csrf as its protection mechanism, OR the action is public-facing (not internal/VPN-gated) AND no Origin check is present |
sameSite: "lax" | App has no library-based CSRF protection AND no Origin check — "lax" then becomes the only defense and is insufficient |
Missing secure flag | Cookie config is the production session/CSRF cookie (not a test fixture or commented example) |
meta returning [] | The route is documented as needing route-specific tags (e.g. a public landing page) — empty is usually intentional inheritance, do not flag by default |
Auth check in action not loader | Route is GET-renderable (has a loader) — for POST-only routes, action is the correct gate |
Logout in action AND <Form method="post"> | Never flag — that is the canonical pattern |
Manual fetch POST | The target is an internal Remix action AND no CSRF token is attached via headers |
secrets: [singleValue] | App is in production OR has been deployed for long enough to need rotation — flag as recommendation, not CRITICAL |
Hard gates (before writing findings)
Run these in order. Do not draft user-facing findings until every gate passes for the batch you are about to report.
1. Location evidence — Pass: Each issue lists a repo path and either a line range or a short verbatim quote from the file you read. Diff-only or memory-based claims do not pass. For meta/links/session issues, the cited file is a .ts/.tsx route module, root.tsx, or *.server.ts — not a generic config file.
2. Exemption check — Pass: For each issue, you can state in one line why it is not covered by Valid Patterns (Do NOT Flag). In particular: sameSite: "lax", empty meta/links arrays, and the standard flash + commitSession two-line pattern must be explicitly cleared.
3. Meta-shape check — Pass: Before flagging anything about meta, you read the actual function body and confirmed what it returns. TypeScript may have masked the shape (a v1 object can satisfy a poorly-typed MetaFunction alias). The check is: the return expression starts with [ and every element is a descriptor object. If it starts with {, that is the v1 shape — flag as CRITICAL. If it is [], that is valid (do not flag).
4. Protocol — Pass: You completed the Pre-Report Verification Checklist in review-verification-protocol for this review.
When to Load References
- Reviewing any
export const metaorexport const links, orroot.tsx→ meta-v2-shape.md - Reviewing
createCookieSessionStorage,createCookie, or any*.server.tsthat configures cookies → cookie-security.md - Reviewing loaders/actions that read or write
session, or any auth helper → auth-gates.md - Reviewing forms, fetchers, or any mutating route → csrf.md
Review Questions
1. Does every meta export return an array, and is every OG/Twitter tag { property, content }? 2. Does root.tsx include <Meta /> and <Links /> inside <head>? 3. Are cookies httpOnly + secure: NODE_ENV === 'production' with secrets from env in an array (rotation-ready)? 4. Is every session mutation followed by a Set-Cookie: await commitSession(session) header? 5. Is auth gated in the loader/action via a throwing helper, never in a component? 6. Is logout an action (POST), and do mutating actions validate CSRF (or document the threat model)?
Additional Documentation
- references/meta-v2-shape.md — v1 object shape in v2 codebases (BREAKING), OG shorthand,
document.titleantipatterns, root scaffolding, parent merging - references/cookie-security.md —
httpOnly/secure/sameSite, hardcoded secrets, rotation hygiene - references/auth-gates.md — loader-level gates, logout-must-be-action, commit pairing, flash patterns
- references/csrf.md —
remix-utils/csrfwiring, manual-fetch bypass, dedicated cookie, shared-secret hygiene
Before Submitting Findings
Complete Hard gates (especially gate 3 — meta-shape check), then report only issues that still pass the review-verification-protocol pre-report checks.
Auth Gates — Anti-Patterns
Remix has no built-in auth. The convention is a requireUserId(request) helper that throws redirect() from inside loaders and actions, paired with commitSession/destroySession on every mutation. The common failure modes are gating in the wrong layer (component instead of loader), missing commitSession, and logout-as-GET (CSRF-able).
See remix-v2-meta-sessions for the canonical pattern.
1. Auth check in a React component (instead of loader)
Anti-pattern:
// BAD — SSRs protected HTML and ships loader data to unauthenticated users
export default function Dashboard() {
const user = useUser();
if (!user) return <Navigate to="/login" />;
return <PrivateContent />;
}Why bad: Remix renders the entire route tree on the server. The loader runs, fetches private data, and ships it down in the HTML payload. By the time the React component decides "no user, redirect," the secret data is already on the wire. The client then double-renders: a brief flash of <PrivateContent /> (or nothing if the loader threw), then the redirect.
This pattern also creates a race: the protected component renders against loader data that may be null or partial, often causing runtime errors before the redirect fires.
Fix: Gate in the loader. Throw the redirect — Remix short-circuits the request and never invokes the component.
// app/auth.server.ts
import { redirect } from "@remix-run/node";
import { getSession } from "./session.server";
export async function requireUserId(request: Request): Promise<string> {
const session = await getSession(request.headers.get("Cookie"));
const userId = session.get("userId");
if (!userId) {
const url = new URL(request.url);
const redirectTo = `${url.pathname}${url.search}`;
throw redirect(`/login?redirectTo=${encodeURIComponent(redirectTo)}`);
}
return userId;
}
// app/routes/dashboard.tsx
export async function loader({ request }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
// ...fetch only data this user is allowed to see
return json({ userId });
}Detection: Search for <Navigate to= and useNavigate() calls in route components that also have a loader export. Any auth check at the component level is suspect.
2. Logout implemented in a loader
Anti-pattern:
// app/routes/logout.tsx — BAD
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get("Cookie"));
return redirect("/", {
headers: { "Set-Cookie": await destroySession(session) },
});
}
// Used as: <Link to="/logout">Log out</Link>Why bad: Loaders run on GET requests. Any third-party page can trigger logout by including <img src="https://yoursite.com/logout">, or by linking from a malicious page. This is a classic CSRF vector — the Remix sessions docs explicitly call it out.
Beyond CSRF, GET requests should be idempotent and safe per HTTP semantics. Logout mutates server state (destroys the session); it must be a POST.
Fix: Move to an action and use <Form method="post">.
// app/routes/logout.tsx — GOOD
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get("Cookie"));
return redirect("/", {
headers: { "Set-Cookie": await destroySession(session) },
});
}
// Used as:
<Form method="post" action="/logout">
<button type="submit">Log out</button>
</Form>Detection: Any route module named logout.* with a loader export is almost always wrong. Also flag any <Link to="/logout"> regardless of the route's implementation — if the route correctly uses an action, <Link> will hit the loader and 404 or do nothing.
3. Session mutation without commitSession
Anti-pattern:
// BAD — session.set runs, but no Set-Cookie header; mutation is lost
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get("Cookie"));
session.set("userId", user.id);
return json({ ok: true }); // no headers
}Why bad: Remix does NOT auto-commit sessions. session.set mutates the in-memory session object; without commitSession, the response has no Set-Cookie header and the browser keeps the old cookie. The login "succeeds" but the user is not actually logged in.
This bug is silent: typecheck passes, the action returns 200, the form reports success — but the next request has no session.
Fix: Every mutation that writes to session (including session.set, session.unset, session.flash) must produce a response with "Set-Cookie": await commitSession(session).
session.set("userId", user.id);
return redirect("/dashboard", {
headers: { "Set-Cookie": await commitSession(session) },
});Detection grep: Find session.set(, session.unset(, session.flash(. For each, read forward to the next return and confirm the response includes commitSession. If the function calls redirect() or json() without a headers.Set-Cookie, that is the bug.
4. session.flash without commitSession on the reading side
Anti-pattern:
// app/routes/login.tsx loader — BAD
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get("Cookie"));
const error = session.get("error"); // reads flash
return json({ error }); // no commit — flash persists forever
}Why bad: Flash messages are read-once: Remix clears them when the session is committed after the read. Returning the loader response without commitSession leaves the flash in the cookie. On the next request the user sees the same error again. Worse, depending on read order across loaders, the flash may appear to clear on some requests and not others.
Fix: After reading a flash, commit the session and attach the header. This is the standard 2-line pattern — session.flash and commitSession on consecutive lines is correct; do not confuse it with anti-pattern #3.
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get("Cookie"));
const error = session.get("error");
return json(
{ error },
{ headers: { "Set-Cookie": await commitSession(session) } },
);
}Note for reviewers: A loader that calls session.get(key) for a non-flash key (e.g. userId) does NOT need to commit — get does not mutate. Only flash reads trigger the auto-clear-on-commit behavior.
Patterns NOT to flag
- Auth check in an `action` (not loader) — correct for POST-only
routes. Logout, delete, settings updates all gate via requireUserId inside action, not loader.
- `throw redirect(...)` — canonical pattern; the throw is intentional
and short-circuits the loader.
- `commitSession` in a loader — required when reading a flash (#4
above). Do not flag as "session shouldn't be mutated in a loader."
- `flash` immediately followed by `commitSession` on the next line —
the standard pattern.
- `requireUserId` returning early via throw — no top-level
return
is needed in the loader; the thrown response is the exit.
Detection notes for reviewers
- Open every route module under
app/routes/. For eachloaderand
action, ask: does this require auth? If yes, is requireUserId called? If the answer is "auth is checked in the component," that is the bug.
- Grep
<Link to="/logout"and<Link to={routes.logout}— almost
always wrong.
- Grep
session.set(,session.unset(,session.flash(. Verify each
is followed by a response with commitSession in the headers.
- For loaders that read user-facing errors: confirm
commitSessionon
the response if the data came from session.get of a key written via session.flash.
Hard gates reminder
Before flagging an auth issue, confirm the route is actually intended to be protected (read the route's purpose). Public routes do not need requireUserId, and flagging a missing auth gate on a public route is a false positive.
Cookie Security — Anti-Patterns
Remix sets no secure defaults on cookies. Every flag is caller responsibility. The common failure mode is a createCookieSessionStorage config that ships to production missing httpOnly, secure, or rotation support.
See remix-v2-meta-sessions for the canonical setup.
1. Missing httpOnly
Anti-pattern:
// BAD — no httpOnly; cookie is readable from JavaScript
export const { getSession, commitSession } = createCookieSessionStorage({
cookie: {
name: "__session",
secure: true,
sameSite: "lax",
secrets: [process.env.SESSION_SECRET!],
},
});Why bad: Without httpOnly: true, any XSS payload that lands on the page can read the session cookie via document.cookie and exfiltrate it. Defense-in-depth: even if your CSP catches one XSS vector, httpOnly makes the cookie inert.
Fix: Add httpOnly: true. There is no legitimate reason to read a session cookie from client JS — anything the page needs should come from loader data.
cookie: {
name: "__session",
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
secrets: [process.env.SESSION_SECRET!],
},2. Missing or hardcoded secure
Anti-pattern A — missing:
// BAD — no secure flag; cookie sent over HTTP
cookie: {
name: "__session",
httpOnly: true,
sameSite: "lax",
secrets: [process.env.SESSION_SECRET!],
},Anti-pattern B — hardcoded `true`:
// BAD — breaks local development; cookie never set on http://localhost
cookie: {
name: "__session",
httpOnly: true,
secure: true,
sameSite: "lax",
secrets: [process.env.SESSION_SECRET!],
},Why bad: Missing secure allows the cookie to be sent over plain HTTP — any network-level adversary can sniff it. Hardcoding secure: true blocks the cookie from being set at all on http://localhost, so developers see "login does nothing" and either disable security entirely or invent workarounds.
Fix: Tie secure to NODE_ENV so it is true in production and false in development:
secure: process.env.NODE_ENV === "production",3. Hardcoded session secrets in source
Anti-pattern:
// BAD — secret is now in git history forever
cookie: {
name: "__session",
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
secrets: ["dev-secret-please-change"],
},Also bad: A committed .env.example with a realistic-looking secret. Junior devs copy it to .env and never rotate.
Why bad: Anyone with read access to the repo (current contributors, former contributors, anyone who saw a leaked archive) can forge session cookies and impersonate any user. The blast radius scales with the user base.
Fix: Read from environment with a fail-fast guard. Never commit real values. Use .env.example only for keys, not values — or use clearly fake placeholders like __set_a_strong_secret__.
const SESSION_SECRET = process.env.SESSION_SECRET;
if (!SESSION_SECRET) throw new Error("SESSION_SECRET is required");
export const { getSession, commitSession, destroySession } =
createCookieSessionStorage({
cookie: {
name: "__session",
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
secrets: [SESSION_SECRET],
},
});4. Single-string secrets with no rotation plan
Anti-pattern A — string, not array:
// BAD — type-coerces in some Remix versions, but blocks rotation
secrets: process.env.SESSION_SECRET!, // string, not string[]Anti-pattern B — array with only one entry, no rotation slot:
// BAD — works, but no migration path when you need to rotate
secrets: [process.env.SESSION_SECRET!],Anti-pattern B is not a CRITICAL finding on its own — it works correctly. Flag it as a recommendation when reviewing a session config that has been in production long enough to need rotation, or when adjacent code suggests the team is unaware rotation is supported.
Why it matters: secrets is an array because Remix signs new cookies with secrets[0] and accepts any entry for verification. That is the mechanism for rotating without invalidating existing sessions.
Fix: Always declare as an array; add an _OLD env slot for rotation.
secrets: [
SESSION_SECRET,
...(process.env.SESSION_SECRET_OLD ? [process.env.SESSION_SECRET_OLD] : []),
],5. Replace-not-prepend secret rotation
Anti-pattern: Operator deploys with SESSION_SECRET changed to a new value and no SESSION_SECRET_OLD:
# BAD — instant logout for every user
SESSION_SECRET=new-strong-secretWhy bad: Remix signs with secrets[0] and verifies with any array entry. If you replace the only entry, every existing cookie fails verification and every user is logged out. Worse, users may interpret the mass logout as a security incident.
Fix: Prepend the new secret. Keep the old one in secrets for at least maxAge (so existing sessions remain valid until they expire naturally), then remove it.
SESSION_SECRET=new-strong-secret
SESSION_SECRET_OLD=previous-strong-secret # remove after maxAge elapsesDetection: a PR that changes SESSION_SECRET in deployment config without adding/keeping SESSION_SECRET_OLD is a smell — flag with a note about the rotation pattern.
6. Cookie-specific edge cases
`sameSite` choice:
"lax"— acceptable default. Cookies sent on top-level navigations
(including form GETs) but not on cross-origin sub-requests. Do NOT flag "lax" unless the app relies on the session cookie alone for CSRF protection (no remix-utils/csrf and no Origin checks).
"strict"— required if you're using session cookie alone for CSRF
defense.
"none"— REQUIRESsecure: true. Flag any"none"withoutsecure,
and flag any "none" without a documented cross-site use case (e.g. iframe embeds, OAuth callbacks).
Missing `path`: Defaults to the path of the request that set the cookie. Most apps want path: "/" so the cookie covers all routes; omitting it is rarely intentional. Flag as a minor issue.
Missing `maxAge`/`expires`: Cookie becomes a session cookie (cleared on browser close). May be intentional for short-lived auth; ask the author rather than flagging blindly.
Detection notes for reviewers
- Search every
createCookieSessionStorage(andcreateCookie(call.
Open each and verify: httpOnly, secure, sameSite, secrets.
- Search for
secrets: [followed by a string literal — that is the
hardcoded-secret pattern.
- Search
.env.exampleand any committed env files for realistic-looking
values.
git log -p -- .env*to confirm no real secret was ever committed.
If one was, secret rotation is required regardless of current state.
Hard gates reminder
Before flagging cookie config, confirm the file is actually wiring the production session — not a test fixture, demo, or commented-out example. Read the surrounding module to verify the exported commitSession is imported by route modules.
CSRF — Anti-Patterns
Remix has no built-in CSRF protection. The community convention is remix-utils/csrf with a dedicated signed cookie, an AuthenticityTokenProvider in root.tsx, and csrf.validate(request) in every mutating action. The common failure modes are no protection at all, shared session/CSRF cookies, manual fetch POSTs that bypass token injection, and shared secrets.
See remix-v2-meta-sessions for the canonical wiring.
1. No CSRF protection at all
Anti-pattern: App ships without any token validation, relying on "Remix is safe by default."
Why bad: Remix is not safe by default. The only protection against cross-origin POSTs is whatever SameSite value the session cookie carries. SameSite=Lax blocks cookies on cross-site POST navigations in all current browsers. (Chrome briefly had a 2-minute "Lax+POST" window in 2020 — removed in 2021.) The real Lax-vs-Strict tradeoff is subdomain takeover: with Lax, a compromised subdomain can initiate top-level GET nav with credentials; with Strict, deep-link navigations from external sites lose session. Additional gaps:
- Subdomain takeovers: an attacker controlling
evil.example.comcan
forge POSTs to app.example.com since SameSite treats sibling subdomains as same-site.
- Apps that use
SameSite=Nonefor legitimate cross-site needs (OAuth
popups, iframe embeds) have no cookie-level CSRF protection at all.
Fix: Add remix-utils/csrf. Wire the provider in root.tsx, the input in every <Form>, and csrf.validate(request) at the top of every mutating action.
// app/utils/csrf.server.ts
import { createCookie } from "@remix-run/node";
import { CSRF } from "remix-utils/csrf/server";
export const csrfCookie = createCookie("csrf", {
path: "/",
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
secrets: [process.env.CSRF_SECRET!],
});
export const csrf = new CSRF({
cookie: csrfCookie,
secret: process.env.CSRF_SECRET!,
});Acceptable alternative: If remix-utils/csrf is not in use, the app must (a) set sameSite: "strict" on the session cookie AND (b) verify the Origin header in every action. Document the threat model in the repo. Flag any app that does neither.
2. CSRF token stored in the session cookie
Anti-pattern:
// BAD — reuses the session cookie for CSRF; types don't match
export const csrf = new CSRF({
cookie: sessionCookie, // same cookie used for session storage
secret: process.env.SESSION_SECRET!,
});Why bad: The session cookie value is a serialized object (the session data). The CSRF cookie value is a signed string (the token). remix-utils/csrf writes its own value to the configured cookie; if that cookie is also being used for session storage, every commit clobbers the other.
At runtime: csrf.validate(request) throws on every request because the serialized session object does not match the signed-token format. Alternatively, session reads return undefined because CSRF overwrote the value.
Fix: Use a dedicated createCookie("csrf", ...) with its own name and its own secret env var. The CSRF cookie and the session cookie are two separate cookies, each with their own Set-Cookie header on responses that mutate them.
3. Manual fetch POST bypassing the token
Anti-pattern:
// BAD — skips AuthenticityTokenInput; csrf.validate throws on the server
async function deletePost(id: string) {
await fetch(`/posts/${id}/delete`, { method: "POST" });
}Why bad: Two failure modes:
1. If the action validates CSRF: every manual POST returns 403 because no token was attached. The feature is broken. 2. If the action does NOT validate CSRF: this is the exact entry point an attacker uses. Cross-origin pages can submit the same POST and the server processes it.
Either way, the manual fetch route is wrong.
Fix: Use <Form> or useFetcher().submit(...) so AuthenticityTokenInput (rendered inside the form) or useAuthenticityToken (read inside submit) attaches the token.
import { Form } from "@remix-run/react";
import { AuthenticityTokenInput } from "remix-utils/csrf/react";
export default function DeletePostForm({ id }: { id: string }) {
return (
<Form method="post" action={`/posts/${id}/delete`}>
<AuthenticityTokenInput />
<button type="submit">Delete</button>
</Form>
);
}For programmatic submission, use useFetcher:
const fetcher = useFetcher();
const token = useAuthenticityToken();
function deletePost(id: string) {
const formData = new FormData();
formData.set("csrf", token);
fetcher.submit(formData, { method: "post", action: `/posts/${id}/delete` });
}Detection: Grep fetch("/, fetch(/, and fetch( followed by method: "POST"` (or PUT, PATCH, DELETE). Each call site is suspect — verify whether the target action validates CSRF, and whether the call attaches a token via headers.
4. Shared secrets across session cookie and CSRF
Anti-pattern:
// BAD — one env var feeds both session and CSRF
const SECRET = process.env.APP_SECRET!;
export const sessionStorage = createCookieSessionStorage({
cookie: { name: "__session", secrets: [SECRET], /* ... */ },
});
export const csrf = new CSRF({
cookie: csrfCookie, // separate cookie (good)
secret: SECRET, // same secret (bad)
});Why bad: A compromise of one secret compromises both subsystems simultaneously. Rotation of one forces rotation of the other, so teams either rotate neither or accept higher blast radius. The two concerns have different threat models and lifetimes; they should have independent secrets.
This is also bad rotation hygiene: prepending a new SESSION_SECRET while keeping CSRF_SECRET static means tokens issued before rotation still validate against post-rotation session cookies — which is fine but defeats the point of separate secrets.
Fix: Two env vars, two independent rotation schedules.
const SESSION_SECRET = process.env.SESSION_SECRET!;
const CSRF_SECRET = process.env.CSRF_SECRET!;Patterns NOT to flag
- `<Form method="post">` without explicit CSRF input — only flag if
the app declares remix-utils/csrf as the protection mechanism and the form is missing <AuthenticityTokenInput />. If the app uses another approach (Origin check, SameSite=Strict), absence of AuthenticityTokenInput is correct.
- `csrf.validate(request)` thrown without try/catch — letting
CSRFError propagate to the route error boundary is acceptable. Only flag if the error boundary doesn't return a 403 status.
- `sameSite: "lax"` with CSRF library —
remix-utils/csrfis the
primary defense; "lax" on the session cookie is fine.
Reviewer note: csrf.commitToken return shape
csrf.commitToken(request) returns [token, cookieHeader | undefined]. The cookie header may be undefined when the existing CSRF cookie is still valid. Reviewers should look for the cookieHeader ? { ... } : {} conditional in root.tsx and not flag the empty-headers branch as dead code.
Detection notes for reviewers
- Search
node_modules/.package-lock.jsonorpackage.jsonfor
remix-utils. If absent, the app has no library-based CSRF.
- Search every
actionexport. For each, confirm either
csrf.validate(request) is called or an Origin header check is present, or the action is documented as intentionally unprotected (e.g. a public webhook with its own auth).
- Grep
fetch(insideapp/for any string that looks like an
internal route path. Each one is a candidate for the manual-POST bypass.
- Compare
secrets: [in session config with thesecret:argument in
new CSRF({ ... }). Same env var? Flag it.
Hard gates reminder
Before flagging CSRF gaps, confirm the threat model. Internal admin tools behind a VPN with no public exposure may legitimately skip CSRF. Public-facing apps must have one of: library-based tokens, SameSite=Strict + Origin checks, or documented compensating controls.
Meta v2 Shape — Anti-Patterns
The v1 → v2 meta migration is the single highest-stakes detection in this skill. v2 returns MetaDescriptor[] (an array). The v1 object shape still typechecks in stale codebases and via loose MetaFunction aliases, but the runtime ignores it: the route renders with no title and no meta tags.
See remix-v2-meta-sessions for canonical descriptor reference.
1. v1 object shape used in a v2 codebase (BREAKING — flag as CRITICAL)
Severity: CRITICAL. Silent SEO and social-preview regression in production.
Anti-pattern:
// BAD — v1 shape; v2 ignores this at runtime
export const meta = () => ({
title: "My Page",
description: "A page on my site",
});Why bad: v2 expects an array of descriptors. An object literal does not match the runtime contract; Remix discards it and emits nothing. The page ships with the default browser-tab title (often localhost or the URL) and no OG/Twitter tags. Typecheck passes if @remix-run/* is stale, or if the return is typed loosely, or if the function lacks an annotation.
Fix:
import type { MetaFunction } from "@remix-run/node";
export const meta: MetaFunction = () => [
{ title: "My Page" },
{ name: "description", content: "A page on my site" },
];Detection grep: export const meta followed by => and { (or return {) inside the function body — not [. Read the full return expression; an inline conditional like return cond ? { ... } : [ ... ] needs both branches inspected.
2. v1 OG / Twitter shorthand keys
Anti-pattern:
// BAD — v1 shorthand; key is dropped silently in v2
export const meta: MetaFunction = () => [
{ "og:title": "My Page" },
{ "twitter:card": "summary_large_image" },
];Why bad: v2 has no shorthand for Open Graph or Twitter Cards. The keys do not match any descriptor shape (title, name+content, property+content, tagName, script:ld+json, charset, httpEquiv+content). They render as nothing.
Fix:
export const meta: MetaFunction = () => [
{ property: "og:title", content: "My Page" },
{ name: "twitter:card", content: "summary_large_image" },
];Note: Open Graph uses property=; Twitter Cards use name=. They look similar but are not interchangeable.
3. document.title set in useEffect (or during render)
Anti-pattern:
// BAD — bypasses SSR; bots and previews see the default title
export default function Page() {
useEffect(() => {
document.title = "My Page";
}, []);
return <h1>...</h1>;
}Why bad: The server renders the document <head> with whatever <Meta /> aggregates from route exports. Setting document.title on the client only fires after hydration — search bots and social-preview scrapers that do not execute JavaScript see the parent or default title. Users see a visible flash from "Untitled" to "My Page".
document.title inside render (not in an effect) is worse: it causes a hydration mismatch and runs on every render.
Fix: Move to the meta export. If the title depends on loader data, type the export with MetaFunction<typeof loader> and read from data.
export const meta: MetaFunction<typeof loader> = ({ data }) =>
[{ title: data?.title ?? "Loading..." }];4. root.tsx missing <Meta /> or <Links />
Anti-pattern:
// BAD — no <Meta /> or <Links /> aggregators in <head>
export default function App() {
return (
<html>
<head>
<title>My Site</title>
</head>
<body>
<Outlet />
<Scripts />
</body>
</html>
);
}Why bad: Every route meta and links export silently does nothing. The symptom is "css is broken in production" or "meta tags are missing" — no compile error, no runtime warning.
Fix: Both aggregators must live inside <head>. <ScrollRestoration /> and <Scripts /> go at the end of <body>.
import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration }
from "@remix-run/react";
export default function App() {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}5. Missing parent meta merge (no matches.flatMap)
Anti-pattern:
// BAD — leaf returns only its own title; root title/OG tags disappear
export const meta: MetaFunction = () => [{ title: "Dashboard" }];Why bad: v2 picks the last matching route's meta array — it does not merge across the hierarchy. A leaf route with no parent merge ships without site-wide tags (default OG image, canonical site name, etc.).
This trips up reviewers familiar with v1, where parent + child meta merged automatically with last-write-wins per key.
Fix: Use matches to pull parent descriptors, optionally filtering to override specific keys (e.g. title):
export const meta: MetaFunction = ({ matches }) => {
const parentMeta = matches
.flatMap((m) => m.meta ?? [])
.filter((tag) => !("title" in tag)); // child overrides title only
return [...parentMeta, { title: "Dashboard" }];
};Alternative: put truly site-wide tags as plain JSX in root.tsx's <head> so they live outside the <Meta /> aggregator and cannot be displaced.
6. meta reading data without null-guard
Anti-pattern:
// BAD — crashes on 404 / parent loader returning null
export const meta: MetaFunction<typeof loader> = ({ data }) => [
{ title: data.post.title },
];Why bad: meta runs on every render path including error boundaries and 404s. If the loader threw a Response (e.g. notFound()), data is undefined. Accessing data.post.title throws during render, the document fails to render, and the user sees a generic error page instead of the proper 404.
Fix: Guard at the top of the function:
export const meta: MetaFunction<typeof loader> = ({ data }) => {
if (!data?.post) return [{ title: "Not Found" }];
return [{ title: data.post.title }];
};Detection notes for reviewers
- Grep first, read second. Run
rg "export const meta" app/and read
every match — the v1 shape is easy to miss in PR diffs because most reviewers skim past meta exports.
- Check the import.
MetaFunctionmust come from@remix-run/node
(or @remix-run/cloudflare). An import from @remix-run/react or a custom alias may have a loose return type that masks v1 shape.
- Check `tsconfig.json` strictness. A v2 codebase with
"strict": false or no MetaFunction annotation can ship v1 shape without any typecheck failure.
- Look for stale `v2_meta` future flag references. A
remix.config.js
still mentioning v2_meta suggests a partial migration — confirm every meta export was updated.
Hard gates reminder
Before flagging any meta issue, confirm the Meta-shape check in the parent SKILL's Hard gates: you read the actual return expression and it starts with [ (array) or { (object). Diff-only inspection is insufficient.