Authentication is the part of an app where a small mistake becomes a headline. The good news is that app authentication best practices are well understood, and most of the design can be bought rather than built. Most real incidents are not exotic: they come from tokens stored in the wrong place and accounts linked on an unverified email. This tutorial walks an intermediate developer through six steps for a web or mobile app with social login, multi-factor authentication and sessions that can be revoked.

Prerequisites for applying app authentication best practices

  • An identity provider or auth library. Managed options include Auth0, Clerk, Supabase Auth, Firebase Auth and Cognito; self-hosted options include Keycloak and ZITADEL. The steps below are provider-neutral.
  • HTTPS everywhere, including local development with a trusted certificate.
  • A users table that stores an internal user id, verified email, and a list of linked identities; it never stores a password hash if a provider handles passwords.

Step 1: choose the boundary

Decide what your server trusts: a token issued by the provider, verified by signature and audience on every request. Your server never sees a password. If you must handle passwords yourself, use a library's Argon2id hashing with sensible defaults and move on; do not design the scheme.

Step 2: social login with PKCE

Use the OAuth 2.0 authorization-code flow with PKCE for every client, web and mobile. The implicit flow is deprecated and leaks tokens through URLs. The sequence in plain words: the app generates a random code verifier and its SHA-256 challenge; it opens the provider's authorize URL with the challenge and a state value; the provider redirects back with a code; the app exchanges code plus verifier for tokens at the token endpoint. On mobile, open the system browser, not an embedded web view, so the user's existing session and password manager work.

In Node.js 18 or later, the verifier and challenge take two lines:

const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');

Send the challenge with code_challenge_method=S256. Keep the verifier on the client until the callback, then discard it.

Before linking a social identity to an existing account, require the provider's email to be marked verified and matching, or ask the user to sign in to the existing account first. Automatic linking on an unverified email is a known account-takeover path.

Step 3: multi-factor authentication

Offer passkeys first. They are phishing-resistant, synced across the user's devices and faster than any code. Implementation is a WebAuthn registration ceremony (server issues a challenge, the device returns a public key credential, the server stores the public key and credential id) and an assertion ceremony at login. Most providers expose both as two calls.

Offer TOTP second, for users without passkey-capable devices: generate a secret, show it as a QR code, verify one code before enabling, store the secret encrypted. Keep SMS codes as a recovery fallback only; SIM swaps make them a weak factor. Always issue ten single-use recovery codes at enrolment and show them once.

Step 4: sessions and tokens

Access tokens live for five to fifteen minutes and are sent as a bearer header. Refresh tokens live for days to weeks, rotate on every use, and are stored HttpOnly and Secure with SameSite set to Lax or Strict on the web, and in the platform's secure storage (Keychain, Keystore) on mobile. The server keeps a record of each refresh token family so a reused old token revokes the whole family; that is how you detect theft.

Store a session row per device with user id, device label, created and last-seen times, and a revoked flag. The account page lists sessions and lets the user revoke any of them; password or MFA changes revoke all others.

Step 5: the server-side checks that matter

  • Verify the token's signature against the provider's published keys, cached and refreshed on key rotation.
  • Check issuer, audience and expiry on every request. A valid signature with the wrong audience is a token for a different app.
  • Rate-limit login, MFA and recovery endpoints per account and per IP, with a slow-down rather than a hard lock to avoid denial-of-service against your own users.
  • Log authentication events (login, MFA enrol, session revoke, password change) with device and approximate location, and show them to the user.

With the jose library (v5) in Node.js, the first two checks are short:

const JWKS = createRemoteJWKSet(new URL(jwksUri));
const { payload } = await jwtVerify(token, JWKS, { issuer, audience });

jwtVerify rejects expired tokens by default. createRemoteJWKSet caches keys and refetches when it sees an unknown key id.

Step 6: account recovery

Recovery is where attackers go when the front door is strong. Email magic links expire in fifteen minutes and are single-use. Recovery never bypasses MFA; it replaces one factor with a recovery code or a verified device. Support staff cannot reset MFA without a documented identity check.

The two mistakes that cause production incidents

Long-lived tokens in local storage. A JavaScript-readable token that lasts a month is one cross-site script away from a stolen account. HttpOnly cookies on the web, secure storage on mobile, short lifetimes everywhere.

Trusting the provider's email without the verified flag. Some providers let users set any email on their profile. Linking on that value lets an attacker claim someone else's account.

How to test it

  • Automated tests for token verification with a wrong audience, an expired token and a tampered signature; all must fail.
  • Replay a rotated refresh token and confirm the session family is revoked.
  • Register a passkey on one device and sign in on another; confirm the TOTP fallback path.
  • Attempt social login with an unverified email that matches an existing account; confirm it is blocked.
  • Rate-limit test: fifty failed logins in a minute should slow, not lock.

We build authentication like this on most client projects, usually on top of a managed provider. If yours needs a second look, ask us about it.

Frequently asked questions

Should we build our own auth to avoid vendor lock-in?

No. Use a provider that supports standard OIDC and exports users; that is the lock-in protection. The risk of a custom system outweighs the portability gain.

Are passkeys ready for mainstream users?

Yes on current iOS, Android, macOS and Windows with synced credentials. Keep TOTP as the fallback for the rest.