AI-coded apps

    Unsafe Metadata Exposed in Low-Code / Clerk AI App Deployments

    A Next.js app gating a paid tier on Clerk unsafeMetadata, with a browser console rewriting the client-writable field to self-promote.

    Clerk unsafe metadata is exposed to the client by design: unsafeMetadata can be read and written from the frontend, so if your Next.js auth stores a plan, tier, or role there and gates features on it, a user can rewrite that value in their browser and grant themselves access. Per Clerk's own docs, it is called unsafe metadata precisely because a malicious user can tamper with it. The fix is to keep authorization data out of unsafeMetadata: store it in publicMetadata or privateMetadata, which only your backend can set, and enforce every gate on the server rather than trusting a client-writable field.

    Short answer

    If a low-code or AI-generated app puts a user's subscription tier, role, or entitlement in Clerk's unsafeMetadata and checks it to grant paid or privileged features, that check is exploitable. Clerk documents unsafeMetadata as readable and writable from the frontend, so a signed-in user can call the client update method and set their own tier to a paid value. The correct place for authorization data is publicMetadata, which the Clerk metadata docs say can be read on the frontend but set only on the backend, or privateMetadata for data the frontend should never see. Then verify the value server-side, because per OWASP access control must be enforced in trusted server-side code, never on values the client controls.

    What Clerk's unsafeMetadata actually is

    Clerk gives every user three metadata buckets, and the difference between them is entirely about who can write them. Per Clerk's documentation, publicMetadata can be read by both the frontend and the backend but can only be set on the backend; privateMetadata can only be read and set by the backend and webhook handlers, which suits sensitive data you do not want to expose to the frontend; and unsafeMetadata can be both read and set from the frontend and the backend. So the three differ not in what they can hold but in the trust boundary around writing them.

    Clerk names the third one unsafe on purpose. Its docs state it is called unsafe metadata because it can be modified directly from the frontend, which means malicious users could potentially tamper with these values. The reason it exists is convenience: it is the only metadata type that can be set during sign-up, which makes it handy for custom onboarding flows where you want to capture a preference before a backend session exists. That convenience is exactly what makes it the wrong home for anything that decides what a user is allowed to do.

    Why does it bypass backend trust?

    It bypasses backend trust because the value lives on the client and the client can change it, so any decision made from it is a decision made from attacker-controlled input. A signed-in user can call Clerk's client-side user update method and write a new unsafeMetadata object, for example setting a plan field to a paid value or a role field to admin. Clerk applies that write because writing unsafeMetadata from the frontend is a supported operation, not a bug. Your gating code then reads the tampered value and grants access.

    The underlying principle is the one OWASP puts at the top of its list: broken access control happens when authorization is enforced somewhere the user can influence. A field the browser can write is not a source of truth about entitlement; it is user input. Trusting it is the same mistake as trusting a hidden form field or a cookie the client sets. The server has to decide access from data the client cannot alter, which unsafeMetadata by definition is not.

    How the exposure happens in AI-generated apps

    AI code generators reach for unsafeMetadata because it is the path of least resistance during sign-up, and the model optimizes for code that runs, not code that resists tampering. When you ask a tool to build a Next.js app with plans, the quickest way to attach a plan to a user at registration is unsafeMetadata, since it is the one bucket settable from the client during the sign-up flow. The generated code compiles, the demo works, and the tier shows up in the UI, so nothing signals that the field is client-writable.

    This is the same class of mistake as client-trusted gating in other vibe-coded stacks, where the generated app checks entitlement in the browser rather than on the server. The app looks correct because a normal user never opens developer tools to rewrite their own metadata. The gap only appears when someone does, and because the pattern is copied verbatim from a prompt, one insecure snippet can spread across every AI-built app that reused it. Reviewing where entitlement is stored is the step that generation skips.

    Impact on pricing tiers and gating

    The direct impact is that paid tiers and privileged roles stop being enforceable, because a user can move themselves into them for free. If unsafeMetadata holds the subscription tier and your UI or API grants access to pro features when that field says pro, a user rewrites the field and takes pro features without paying, which is straight revenue loss. If it holds a role and your app grants admin views or actions when the role says admin, the same edit becomes privilege escalation into functionality that should never have been reachable.

    The severity depends on what sits behind the gate. A cosmetic feature flag is low harm; an API that returns other users' data or performs billing actions when it sees the client-set role is high harm. Because the tamper is trivial and repeatable, treat any authorization decision that reads unsafeMetadata as already broken rather than as a theoretical risk. The blast radius is every capability your code grants on the strength of that field.

    The three metadata types compared

    Choosing the right bucket comes down to who is allowed to write it, which the table below lays out.

    Metadata typeFrontend accessBackend accessUse for
    publicMetadataRead onlyRead and writeNon-secret data users may see but not change, like tier or role
    privateMetadataNo accessRead and writeSensitive data the frontend must never see, like a Stripe ID
    unsafeMetadataRead and writeRead and writeUser-set preferences captured at sign-up, never authorization

    Read the table as a rule: anything that decides access belongs in publicMetadata or privateMetadata, never in the row the frontend can write.

    Alternatives in auth tools

    The fix inside Clerk is to move the entitlement into publicMetadata, set it only from your backend after a trusted event such as a verified Stripe webhook, and read it on the frontend for display only while the real check runs on the server. You can surface publicMetadata into the session token as a claim so your Next.js server code and route handlers verify the tier or role from a signed token rather than from a mutable client field. Use privateMetadata for anything the browser should never even see.

    The same shape holds across auth tools, because the principle is not Clerk-specific. Firebase expresses trusted authorization through custom claims set with the Admin SDK on the server, not through client-writable profile fields. Auth0 separates app_metadata, which the user cannot edit, from user_metadata, which is user-editable, so authorization data belongs in app_metadata. Supabase enforces access with row-level security keyed on the authenticated user id and columns only a service-role key can write. In every case, the writable-by-user field is off limits for authorization, and the server is the enforcer.

    How to test your own app responsibly

    On your own test account, in your own app, you can confirm the flaw without any special tooling by checking whether a client-writable field changes what you are allowed to do. Sign in as a low-tier test user, open the browser developer console on your app, and call Clerk's client-side user update to set unsafeMetadata to a higher tier or role. Then reload and see whether the app grants features it should not. If it does, your gate is reading a client-controlled value.

    Keep this to accounts and apps you own or are authorized to assess, which is the boundary for any responsible security testing. The goal is to prove where the decision is made, not to access anyone else's data. If flipping the field changes your access, the remediation is to stop reading unsafeMetadata for the decision and re-run the same test against the server-enforced version, where changing the client field should no longer have any effect because the backend is deciding from publicMetadata or a token claim.

    Remediation checklist

    Working through these steps closes the exposure. The checklist below covers them.

    StepActionDone?
    Find the readsSearch the codebase for unsafeMetadata used in any access decision[ ]
    Move the dataStore tier and role in publicMetadata or privateMetadata[ ]
    Set server-sideWrite that field only from the backend after a verified event[ ]
    Enforce on the serverCheck the tier or role in server code or a token claim[ ]
    Keep frontend read-onlyUse the value in the UI for display, not for the gate[ ]
    RetestConfirm editing the client field no longer changes access[ ]

    The step that most often gets missed is the last one, since it is what proves the gate now ignores the value the client can write.

    Where a scan fits

    Once you have moved the data, verifying the whole app for the same class of issue is worthwhile, because client-trusted authorization rarely appears only once.

    A scanner like PTKD.com analyzes your build and flags patterns such as authorization decisions made from client-controlled values, exposed keys, and over-broad permissions by severity, mapped to OWASP MASVS, so a copied unsafeMetadata gate is caught rather than shipped. To be clear about the boundary: PTKD reports findings for you to fix; it does not rewrite your Clerk configuration or enforce access for you. It points at the client-trusted checks so you can move each one server-side.

    What to take away

    • Clerk's unsafeMetadata can be read and written from the frontend, so it is unsafe for any value that decides what a user may do.
    • Storing a tier or role there lets a user rewrite it in the browser and self-grant paid features or privileged roles, which is revenue loss and privilege escalation.
    • Put authorization data in publicMetadata or privateMetadata, which only the backend can set, and enforce every gate in server-side code or a signed token claim.
    • The same rule holds in Firebase custom claims, Auth0 app_metadata, and Supabase row-level security: never authorize from a user-writable field.
    • Test on your own account by editing the client field, and confirm a fixed app ignores it; a tool like PTKD.com helps catch the pattern app-wide.
    • #clerk
    • #unsafemetadata
    • #nextjs auth
    • #access control
    • #authorization

    Frequently asked questions

    Is Clerk's unsafeMetadata safe to store a user's plan or role in?
    No. Per Clerk's documentation, unsafeMetadata can be read and written from the frontend, and it is named unsafe because a malicious user can tamper with the values. If you store a subscription tier or role there and gate features on it, a signed-in user can rewrite the field in their browser and grant themselves access. Store authorization data in publicMetadata, which only the backend can set, or in privateMetadata, and enforce the check on the server.
    Why does gating on unsafeMetadata bypass backend trust?
    Because the value lives on the client and the client can change it, so any decision made from it is made from attacker-controlled input. A user can call Clerk's client-side user update method and set unsafeMetadata to a paid tier or an admin role, and Clerk applies the write because writing unsafeMetadata from the frontend is a supported operation. That is broken access control per OWASP: authorization must be enforced in trusted server-side code, never on a value the client controls.
    What is the difference between publicMetadata, privateMetadata, and unsafeMetadata?
    They differ by who can write them. publicMetadata can be read by the frontend and backend but set only on the backend, so it suits data users may see but not change, like a tier. privateMetadata can only be read and set by the backend and webhook handlers, for sensitive data the frontend should never see, like a Stripe ID. unsafeMetadata can be read and written from the frontend, so it is for user-set preferences captured at sign-up, never for authorization.
    How do I fix an app that gates on unsafeMetadata?
    Search the codebase for unsafeMetadata used in any access decision, move the tier or role into publicMetadata or privateMetadata, and set that field only from your backend after a verified event such as a Stripe webhook. Enforce the gate in server-side code, or surface publicMetadata into the session token as a claim and check it there, while the frontend uses the value only for display. Then confirm that editing the client field no longer changes access.
    How can I test whether my own app is vulnerable?
    On your own test account in your own app, sign in as a low-tier user, open the browser developer console, and call Clerk's client-side user update to set unsafeMetadata to a higher tier or role, then reload and see whether the app grants features it should not. Keep this to accounts and apps you own or are authorized to assess. If flipping the field changes your access, the gate is reading a client-controlled value and must be moved server-side.
    Do other auth tools have the same client-writable metadata risk?
    Yes, the principle is not Clerk-specific. Firebase expresses trusted authorization through custom claims set with the Admin SDK on the server, not through client-writable profile fields. Auth0 separates app_metadata, which the user cannot edit, from user_metadata, which is editable, so authorization belongs in app_metadata. Supabase enforces access with row-level security keyed on the authenticated user id and columns only a service-role key can write. In every case, never authorize from a user-writable field.

    Keep reading

    Scan your app in minutes

    Upload an APK, AAB, or IPA. PTKD returns an OWASP-aligned report with copy-paste fixes.

    Try PTKD free