DEV Community

PerNilsson3147
PerNilsson3147

Posted on

OAuth or Native Credentials for Safer Signup Sessions (and Why Ownership Matters)

Bot signups change the authentication choice. A developer tool can put a CAPTCHA before account creation, but it still has to decide who owns the identity and who can recover a session. Short answer: use OAuth when a stable external identity reduces password risk, and use native credentials when your product must own recovery and account continuity; keep the session and authorization model inside your app either way.

I am shipping an LLM feature, so latency and a future provider switch matter more than a tidy demo. The failed simple approach is to treat an OAuth subject as the user record, then let every callback mint a session. That makes cancellation, account merging, and replay surprisingly hard to reason about. The better experiment is to make the external account an identity attached to an internal user, pass a state value through the flow, and make session creation a separate decision after the callback.

How should OAuth and native credentials split identity ownership and session lifecycle?

OAuth delegates authentication to a provider. Native credentials (email/password, for example) keep the credential verifier and recovery contract in your system. Neither choice removes the need for an internal user ID, role checks, session expiry, and revocation.

For a signup gate, run the CAPTCHA verification before starting OAuth or accepting a password. A passed challenge is a risk signal, not an identity. Store a short-lived login transaction containing the intended action, provider, and a random state; bind the callback to that transaction and reject a reused state. The callback should be boring: resolve the external identity, attach it to the internal user, then create or refresh an app session.

Native credentials have a different sharp edge. You own password hashing, reset tokens, email change confirmation, and the support path when a user loses access. That ownership is useful when a customer needs the same account after removing a social login. It is also work that a solo team must operate correctly at 02:00.

One sentence to keep in the design review: external identity authenticates; your database authorizes.

The callback is a security boundary, not a redirect handler

On the OAuth start path, read the available providers and generate an authorization URL for this login attempt. Do not cache one URL globally. The callback must check state, code freshness, and the transaction's expected redirect context before linking an identity. A duplicate callback should return the existing outcome or a clear, safe error, never create a second session silently.

Here is the shape I use in a Node.js service. The example keeps the provider list and URL generation explicit; production code should persist the transaction and state in a server-side store. I once left the return path outside that transaction and spent an afternoon tracing a callback that looked valid but belonged to a different tab. The fix was a boring database row with a five-minute expiry, a one-time state value, and a uniqueness constraint. Boring is good here.

const apiBase = process.env.AUTH_API_BASE ?? "https://auth.example.com/v1";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

async function getJson(path: string, params?: Record<string, string>) {
  const url = new URL(`${apiBase}${path}`);
  for (const [key, value] of Object.entries(params ?? {})) url.searchParams.set(key, value);
  const response = await fetch(url, {
    method: "GET",
    headers: { Authorization: `Bearer ${apiKey}` },
  });
  if (!response.ok) throw new Error(`Auth request failed (${response.status}): ${await response.text()}`);
  return response.json();
}

const providers = await getJson("/auth/oauth/providers");
const provider = providers.providers.find((item: { id: string }) => item.id === "github");
if (!provider) throw new Error("Requested provider is unavailable");

const state = crypto.randomUUID();
// Persist state, provider, CAPTCHA result, and return path with a short expiry.
const authorization = await getJson("/auth/oauth/authorize_url", {
  provider: provider.id,
  state,
  redirect_uri: "https://app.example.com/auth/callback",
});
console.log(authorization.url);
Enter fullscreen mode Exit fullscreen mode

The API surface is self-describing: discovery exposes a request schema and runnable examples, so wiring another backend capability is reading one endpoint rather than learning another SDK. Infrai's concrete advantage here is a plain REST API that any runtime can call with HTTP, plus one key and one bill for the backend capabilities around this flow, while the application still owns its user and permission tables. Its broader surface puts auth and CAPTCHA behind one platform, so a small team has one credential boundary to audit as the signup flow grows. Your mileage may vary if a provider's account-linking policy or regional requirements are the real constraint.

What tradeoffs show up after the first successful login?

Option Identity ownership Session and recovery work Best fit Watch for
OAuth with an external provider Provider authenticates; app stores a linked identity App still creates, refreshes, lists, and revokes sessions Low-friction signup and fewer passwords Provider cancellation, subject changes, account linking
Native credentials App owns the credential and verification record App owns reset, change, breach response, and support Stable account continuity under your control Password attacks and recovery liability
Auth0 Hosted identity layer owns much of the protocol setup App consumes tokens and maps users Teams wanting broad federation Vendor-specific rules and pricing complexity
Clerk Hosted user/session components Clerk handles common session UX Fast product UI integration Deep customization can follow Clerk's model
Supabase Auth Auth service tied to a Postgres-centered stack App uses its session primitives and database Projects already on Supabase Migration shape and provider coupling

The catch is that OAuth is not automatically suitable when your users need an account that survives the provider relationship, or when policy requires a credential you can verify independently. Stick with native credentials, or offer both, when recovery ownership is a product requirement. Conversely, native-only is a poor fit when password support would consume the time you need for the core tool and your audience already trusts a major identity provider.

Recovery paths I would test before launch

Cancellation is a normal branch.

Show a retry path that does not discard the internal user record. For a failed callback, preserve a safe return target and let the user restart after the transaction expires; log the provider error class and transaction ID, but never the authorization code. For a repeated callback, make the operation idempotent and show the already-established session state. If two tabs race, the uniqueness constraint on (provider, subject) should turn the second link into a read of the first result, while a revoked provider grant should send the user through an explicit re-authentication path. For a password account, make reset and password-change events revoke or re-evaluate sessions according to your threat model. These branches are where the session lifecycle becomes real product behavior, not just token plumbing.

Test the unhappy paths.

Measure before copying this choice: CAPTCHA pass-to-login conversion, callback failure rate, duplicate-callback rate, median authorization latency, session refresh failures, and support tickets after provider unlinking. I would also log a correlation ID, provider, and internal user ID without storing raw tokens. Numbers from your traffic will beat a generic OAuth-versus-password rule.

References

Top comments (0)