Back to Blog

The moment your web application has a login screen, security stops being optional. Every feature you build behind that login (client dashboards, billing information, internal tools, sensitive data) is only as secure as the authentication and authorization system protecting it.

We build client-facing applications for businesses at Hunter Creative Media, and security is baked into every project from the first conversation. Not as an afterthought, not as a “we’ll harden it later” item. From the beginning. Because the cost of fixing a security breach is orders of magnitude higher than the cost of building it right. If you want the whole picture, we put together the custom web applications guide we wrote for small businesses.

Here’s what goes into building authentication and user role systems that actually protect your users and your business.

Authentication vs. Authorization

These two terms get confused constantly, but they mean different things and require different implementations.

Authentication answers the question: “Who are you?” It’s the process of verifying a user’s identity. When someone enters a username and password, the system checks those credentials against stored records to confirm the user is who they claim to be.

Authorization answers the question: “What are you allowed to do?” Once the system knows who you are, it determines what actions you can perform, what data you can see, and what parts of the application you can access.

A common mistake is treating authentication as sufficient. “The user logged in, so they can do anything.” That works when every user has the same permissions. It falls apart the moment you have admins, managers, staff, and clients who need different access levels, which is almost every business application.

Authentication Methods

There are several ways to implement authentication, each with different trade-offs for security, complexity, and user experience.

Session-Based Authentication

This is the traditional approach. When a user logs in, the server creates a session, a record stored on the server that says “this user is authenticated.” The server sends the session ID to the browser as a cookie. On every subsequent request, the browser sends that cookie back, and the server looks up the session to identify the user.

Advantages:

  • Simple to implement and understand
  • Easy to invalidate (delete the session on the server and the user is logged out immediately)
  • The session ID itself contains no user information: it’s just a random string
  • Built-in support in virtually every web framework (Express, Django, Rails, Laravel)

Disadvantages:

  • Requires server-side storage for sessions (memory, database, or Redis)
  • Harder to scale horizontally because sessions need to be accessible across multiple servers (solved by centralized session stores like Redis)
  • Every request requires a session lookup

When to use it: Server-rendered applications, traditional web apps, and applications where you need the ability to instantly revoke access.

JSON Web Tokens (JWT)

JWTs are self-contained tokens that encode user identity and claims (permissions, roles) directly in the token. The server signs the token with a secret key, and the browser stores it (usually in an HttpOnly cookie or localStorage). On each request, the server verifies the token’s signature to confirm it hasn’t been tampered with.

Advantages:

  • Stateless: the server doesn’t need to store session data, making horizontal scaling simpler
  • Can contain user information and permissions directly in the token, reducing database lookups
  • Works well with APIs and single-page applications
  • Can be used across multiple services (microservices architecture)

Disadvantages:

  • Harder to invalidate. Once issued, a JWT is valid until it expires. If you need to immediately revoke a user’s access, you need a token blacklist (which reintroduces server-side state)
  • Token size is larger than a session cookie because it contains encoded data
  • If stored in localStorage, tokens are vulnerable to XSS attacks. If stored in cookies, they need proper security flags.
  • Misuse is rampant: many developers implement JWTs insecurely

When to use it: API-first applications, single-page apps, mobile app backends, and systems where multiple services need to verify user identity independently.

Our Recommendation

For most client-facing business applications, we use session-based authentication with server-side session storage in Redis. It’s simpler, easier to secure, and provides immediate revocation when needed. We use JWTs when the application architecture specifically requires stateless authentication, like a separate API that serves both a web app and a mobile app.

OAuth and Social Login

OAuth 2.0 is a protocol that allows users to authenticate using an existing account from another service: “Sign in with Google,” “Sign in with Microsoft,” etc. The user never gives your application their password. Instead, the identity provider (Google, Microsoft, Apple, GitHub) confirms the user’s identity and sends your application a token.

When it makes sense:

  • Consumer-facing applications where reducing registration friction matters
  • Applications where users already have accounts with a supported provider
  • Internal tools where everyone uses Google Workspace or Microsoft 365

When it doesn’t make sense:

  • Applications where you need full control over the authentication flow
  • Industries where compliance requires specific credential management (healthcare, finance)
  • Applications where users may not have accounts with common providers

OAuth is often used alongside traditional email/password authentication, giving users the choice. We implement it as an addition to (not a replacement for) standard login when the use case warrants it.

Dashboards and portals turn scattered data into one source of truth.
Dashboards and portals turn scattered data into one source of truth.

Secure Password Handling

If your application stores passwords, getting this wrong has catastrophic consequences. A data breach that exposes plaintext passwords is a business-ending event for many companies.

The Rules

Never store plaintext passwords. This is non-negotiable. If your database is compromised and passwords are stored in plain text, every user’s credentials are immediately exposed. Many users reuse passwords across sites, so a breach of your application can compromise their banking, email, and other accounts.

Use bcrypt, scrypt, or Argon2 for hashing. These are purpose-built password hashing algorithms designed to be slow, intentionally. A fast hash (like MD5 or SHA-256) can be cracked by trying billions of combinations per second. Bcrypt with a proper work factor takes 100-300 milliseconds per hash, making brute-force attacks impractical.

Never use MD5 or SHA for passwords. These are general-purpose hash functions, not password hashing functions. They’re fast by design, which is exactly the wrong property for password storage.

Use a unique salt per password. A salt is a random value added to the password before hashing so that two users with the same password produce different hashes. Bcrypt, scrypt, and Argon2 handle salting automatically. You don’t need to manage salts separately.

Enforce reasonable password requirements. Minimum 8 characters, no maximum length (or set it very high, like 128 characters). Checking against common password lists (like the Have I Been Pwned database) is more effective than requiring special characters. The requirement to include an uppercase letter, a number, and a symbol leads to predictable patterns like “Password1!” which are not genuinely secure.

Password Reset Flow

  • Generate a unique, time-limited token (expires in 15-60 minutes)
  • Send the token to the user’s verified email address
  • The reset link should work exactly once: after use, invalidate the token
  • Never send the actual password in an email
  • Never reveal whether an email address exists in your system when someone requests a reset (“If an account exists with this email, we’ve sent a reset link”, same message regardless)

Two-Factor Authentication (2FA)

Two-factor authentication requires users to provide a second form of verification beyond their password. Even if a password is compromised, the attacker can’t access the account without the second factor.

Implementation Options

Time-based One-Time Passwords (TOTP): The user sets up an authenticator app (Google Authenticator, Authy, 1Password) that generates a six-digit code every 30 seconds. At login, they enter their password plus the current code. This is our default recommendation for business applications: it’s secure, widely understood, and doesn’t require SMS infrastructure.

SMS codes: A code is sent via text message to the user’s phone. While better than no 2FA, SMS is vulnerable to SIM swapping attacks and should not be the only 2FA option for high-security applications.

Email codes: Similar to SMS but sent to the user’s email. Adequate for lower-risk applications but dependent on email security.

Hardware keys (FIDO2/WebAuthn): Physical security keys (YubiKey, Google Titan) or platform authenticators (fingerprint, Face ID) provide the strongest second factor. The WebAuthn API is well-supported in modern browsers and provides phishing-resistant authentication.

Recovery codes: Always provide backup recovery codes when users enable 2FA. These are one-time codes that let users access their account if they lose their authenticator device. Generate 8-10 codes, instruct the user to store them securely, and invalidate each code after use.

When to Require 2FA

  • Always for admin accounts. Anyone with the ability to manage users, access all data, or modify system settings should have 2FA enforced.
  • Strongly recommended for all users of business applications handling sensitive data: financial records, personal information, health data.
  • Optional but encouraged for consumer applications. Give users the choice, but make enabling it easy and prominent.

Role-Based Access Control (RBAC)

Once you’ve confirmed who a user is, you need to control what they can do. Role-based access control assigns permissions to roles, and roles to users.

The Role Structure

A typical business application has roles like:

Super Admin
Full system access, user management, system configuration
Admin
User management within their organization, access to all features
Manager
Access to team data, reporting, approval workflows
Staff
Access to their own data and assigned tasks
Client
Limited access to their own account, project status, invoices

Implementing RBAC

The cleanest implementation uses three database tables:

roles table:

idnamedescription
1adminFull administrative access
2managerTeam management and reporting
3staffStandard employee access
4clientExternal client access

permissions table:

idnamedescription
1users.createCreate new users
2users.readView user list
3users.updateEdit user details
4users.deleteRemove users
5invoices.createCreate invoices
6invoices.readView invoices
7reports.viewAccess reporting dashboard

role_permissions table:

role_idpermission_id
11
12
13
……

This structure lets you modify what each role can do without changing code. When a client says “managers should be able to create invoices but not delete users,” you update the role_permissions table. No code deployment needed.

Permission Checks in Practice

Every protected action in your application should check the user’s permissions before executing.

Route-level protection: Middleware that checks whether the user’s role has permission to access a specific route. If a staff member tries to access /admin/users, the middleware blocks the request before any code runs.

Action-level protection: Within a route that multiple roles can access, check specific permissions before performing actions. A manager viewing the user list is fine. A manager clicking “delete user” gets denied.

Data-level protection: Users should only see data they’re authorized to see. A client should only see their own invoices, not every invoice in the system. A staff member should only see tasks assigned to them or their team.

This last point (data-level access control) is where most security vulnerabilities hide. It’s easy to check “can this user access the invoices page?” and forget to check “can this user access this specific invoice?”

Common RBAC Pitfalls

  • Checking roles instead of permissions. Code that says if (user.role === 'admin') is brittle. When you add a new role, you have to find and update every check. Code that says if (user.hasPermission('users.delete')) works regardless of how many roles exist.
  • Forgetting API-level checks. If your application has a frontend and an API, permissions must be enforced at the API level. Frontend checks prevent UI confusion; API checks prevent actual unauthorized access. A determined user can bypass any frontend restriction.
  • Overcomplicating the role hierarchy. Start with the minimum roles your application needs. You can always add more. Five roles are manageable. Fifteen roles with overlapping permissions become a maintenance burden.
  • Not auditing role changes. Log every change to roles and permissions with timestamps and the user who made the change. When something goes wrong, you need to know who changed what and when.

Security Pitfalls to Avoid

Beyond authentication and authorization architecture, these common mistakes create vulnerabilities.

Session Fixation

If an attacker can set a user’s session ID before they log in, they can hijack the session after authentication. The fix: always regenerate the session ID after a successful login.

Cross-Site Request Forgery (CSRF)

An attacker tricks a logged-in user into making a request to your application (clicking a malicious link, loading a hidden form). Because the browser sends cookies automatically, the request appears legitimate. The fix: use CSRF tokens on all state-changing requests. Every modern framework includes CSRF protection. Make sure it’s enabled.

Cross-Site Scripting (XSS)

If your application displays user-generated content without sanitization, an attacker can inject JavaScript that runs in other users’ browsers, stealing cookies, session tokens, or performing actions as the user. The fix: sanitize all output. Use your framework’s built-in template escaping. Set the Content-Security-Policy header to restrict script execution.

Insecure Direct Object References

A user changes the ID in a URL from /invoices/123 to /invoices/456 and sees another client’s invoice. This is one of the most common web application vulnerabilities. The fix: always verify that the requesting user has permission to access the specific resource, not just the resource type.

Missing Rate Limiting

Without rate limiting on login endpoints, attackers can try thousands of password combinations per minute. Implement rate limiting: after five failed login attempts, add a delay or temporarily lock the account. Also rate limit password reset requests and API endpoints.

Logging Sensitive Data

Never log passwords, session tokens, or authentication credentials. Sanitize logs to remove sensitive fields. Log files are often stored with less security than the database, and leaking credentials through logs is a common breach vector.

Real-World Example: A Client Portal

Here’s how these concepts come together in a real application we built: a client portal for a professional services firm.

Users and roles:

  • Firm admins manage all clients, users, and system settings
  • Account managers see and manage their assigned clients only
  • Clients see their own projects, invoices, and documents

Authentication:

  • Email/password with bcrypt hashing
  • TOTP-based 2FA required for firm admins, optional for others
  • Session-based authentication with Redis
  • Sessions expire after 8 hours of inactivity

Authorization:

  • RBAC with role_permissions table
  • Data-level filtering: account managers only query clients assigned to them
  • Clients are restricted to their own organization’s data across every endpoint
  • Every API endpoint checks both role permissions and data ownership

Security measures:

  • CSRF tokens on all forms
  • Rate limiting on login (5 attempts per 15 minutes per IP)
  • Session regeneration after login
  • All output HTML-escaped
  • Audit log captures every significant action
  • Password reset tokens expire after 30 minutes

The system handles three distinct user types with very different access needs, all through the same application with the same codebase. Adding a new role (they later wanted a “read-only client” role for stakeholders) required adding one database record and assigning the appropriate permissions. No code changes.

The Build vs. Buy Decision

For authentication specifically, there’s a growing ecosystem of managed services: Auth0, Clerk, Supabase Auth, Firebase Authentication, AWS Cognito. These handle the authentication layer so you can focus on your application’s unique logic.

When managed auth makes sense:

  • Your team doesn’t have deep security expertise
  • You want social login, passwordless, and 2FA out of the box
  • You need to ship quickly and authentication isn’t your differentiator
  • You’re building on a platform that integrates with these services naturally

When custom auth makes sense:

  • You need full control over the authentication flow and user experience
  • Compliance requirements dictate how and where credentials are stored
  • The cost of a managed service at your user volume exceeds the cost of building your own
  • You need offline authentication capability

For most client projects, we use a hybrid: a well-tested authentication library (like Lucia, NextAuth, or Passport.js) that runs within our application but handles the cryptographic heavy lifting. This gives us control over the user experience and data storage without reimplementing password hashing and session management from scratch.

Security Is Not a Feature: It’s a Foundation

Authentication and authorization aren’t features you bolt on at the end. They’re the foundation everything else sits on. A beautifully designed client portal with broken authentication is worse than no portal at all: it gives users a false sense of security while potentially exposing their data.

Every decision in this article (password hashing, session management, role-based permissions, 2FA, input sanitization) is a layer in a defense system. No single layer is sufficient. Together, they create an application that your users can trust with their data.

Keep Reading

Security and trust go hand in hand with design: our Heritage Wealth Solutions case study shows how we rebuilt an estate planning firm’s site around exactly that kind of client confidence.

About the Author

Brian Hunter is the founder of Hunter Creative Media & Design, an Aurora, IL studio building hand-coded websites and custom web applications for small businesses across Chicago’s western suburbs. He writes about web performance, local SEO, and the software that runs small businesses. Get in touch.