Skip to content

Authentication

The Northwestern Laravel Starter provides a multi-method authentication system that handles Northwestern users (via NetID/SSO) and external collaborators (via passwordless verification codes), and integrations through OAuth credentials issued by Laravel Passport.

NetID Single Sign-On

Northwestern SSO (Entra ID or Online Passport)

Primary authentication method for Northwestern users via NetID.

  • Entra ID (OAuth2) or Online Passport (cookie-based)
  • Multi-factor authentication support
  • Automatic user provisioning
  • Session-based authentication

Passwordless Verification Codes

Email OTP Authentication

Email-based passwordless authentication for external users.

  • No password management
  • Time-limited verification codes
  • Rate limiting protection
  • Invite-only access

OAuth Clients

Laravel Passport

Client credentials for integrations, which get short-lived access tokens.

  • Multiple clients per API user
  • IP allowlisting
  • Expiring, rotatable secrets

Sign-in belongs to the App panel. Guests who open a page in any panel are sent to /app/login, which lists the sign-in methods this application has configured:

  • Sign In with NetID appears when single sign-on is configured. It uses Online Passport (WebSSO) when that is configured, otherwise Entra ID.
  • Sign In with Email appears when LOCAL_AUTH_ENABLED=true.
  • Sign In As appears in the local environment only. See Signing In Locally.
  • If none is available, the page explains that no sign-in methods are available.

When NetID is the only method, /app/login skips the page and goes straight to single sign-on. The exceptions are the ci environment, where the page always renders so end-to-end tests can sign in with email codes, and local, where it offers Sign In As.

Email sign-in is a single page, /app/login/email, with two steps: request a code, then enter it. Progress is kept in the session, so reloading the page returns to the current step.

Which methods are offered, and the step every method ends with, are decided in App\Domains\Auth\SignIn. Every sign-in finishes with SignIn::complete(): a new session and CSRF token, a login record, and the page the person asked for, or /. An email code sign-in is remembered past the session (Laravel’s remember cookie), since a new code costs a trip to the inbox; single sign-on and Sign In As aren’t, since signing in again is usually silent or one click. SignInMethod::remembers() is where to change that.

A local environment needs no SSO, email or credentials to sign in. In the local environment, the sign-in page has a Development section with a Sign In As… menu of the seeded demo users, each shown with its role:

UserSigns in as
nuit.adminNUIT Administrator, a Super Administrator
generic.userA Northwestern user with no extra roles
partner.userA local (email) account, when LOCAL_AUTH_ENABLED=true

Each menu item is a link to /app/login/as/{username}, which signs in as that user, records the sign-in like any other, and redirects to / (or the page you asked for). Agents and scripts can use the URL directly, at any host or port the application is served on.

The users come from DemoUserSeeder, which db:rebuild runs. Add the users you seed for specific roles to its SIGN_IN_AS list and they appear here too. API users can’t be signed in as.

To test real NetID sign-in locally, configure Entra ID or Online Passport (see Installation). The Azure redirect URI must match the host you use.

Every sign-in path ends at /. HomeController shows guests the landing page and sends signed-in users to destinationFor(), which returns the App panel by default. A user who started from a specific page, for example a link in an email, is returned to that page instead.

To change where signed-in users go, override destinationFor():

app/Http/Controllers/HomeController.php
protected function destinationFor(User $user): string
{
if ($user->can(SystemPermission::AccessAdministrationPanel)) {
return Filament::getPanel(AdministrationPanelProvider::ID)->getUrl();
}
return Filament::getPanel(AppPanelProvider::ID)->getUrl();
}

Northwestern users authenticate via single sign-on using their NetID credentials. The starter supports two SSO providers, Microsoft Entra ID (OAuth2) and Online Passport (agentless WebSSO via ForgeRock), and auto-detects which one to use based on your configured credentials. See the WebSSO / Entra ID documentation for provider-specific details.

  1. User visits protected route

    User attempts to access a route requiring authentication

  2. Redirect to SSO

    The application sends the guest to /app/login. When email sign-in is off, that page redirects straight to Northwestern’s authentication service; otherwise the user picks Sign In with NetID there

  3. User authenticates

    User enters NetID and password (+ MFA if enabled)

  4. Callback with token

    SSO provider redirects back with authentication token

  5. User provisioning

    Application creates/updates user record from Directory Search data

  6. Session established

    User is logged in and session cookie is set

When a Northwestern user logs in for the first time, the application:

  1. Validates NetID from SSO response
  2. Queries Directory Search API for user demographics
  3. Creates user record with demographic data:
    • Full name
    • Email address
    • Department
    • Affiliations (student, faculty, staff, etc.)
    • etc.

Subsequent logins update the user’s demographic data to keep it synchronized.


External users (non-Northwestern) can access the application via time-limited verification codes sent to their email.

  1. Admin creates local user

    Administrator creates a local user account in the Filament panel

  2. Verification code sent

    The user requests a code on /app/login/email, or a super administrator (ManageAll) sends one with Send Verification Code on the user’s page (or the Send a verification code now option when creating the user)

  3. User receives email

    The email contains the code (valid for 10 minutes by default, LOCAL_AUTH_CODE_EXPIRES_MINUTES) and an Enter your code button. The button opens /app/login/email at the code step for that challenge, so a code sent by an administrator or read on another device can be entered. The link alone does not sign anyone in, and an expired, used, locked or altered link is ignored.

  4. User enters code

    On /app/login/email, the code authenticates the user and SignIn::complete() establishes a session

  5. Code expires

    Code becomes invalid after use or expiration

.env
# Enable/disable local authentication
LOCAL_AUTH_ENABLED=true
# Rate limit for login code requests per email (per hour)
LOCAL_AUTH_RATE_LIMIT_PER_HOUR=10
# Rate limit for login code requests per IP (per hour)
LOCAL_AUTH_RATE_LIMIT_PER_IP_PER_HOUR=20
# Use a fixed, predictable verification code instead of random codes
LOCAL_AUTH_USE_FIXED_CODE=false
# Number of digits in a verification code
LOCAL_AUTH_CODE_DIGITS=6
# Minutes before a code expires
LOCAL_AUTH_CODE_EXPIRES_MINUTES=10
# Failed attempts before a challenge locks, and how many minutes it stays locked
LOCAL_AUTH_CODE_MAX_ATTEMPTS=8
LOCAL_AUTH_CODE_LOCK_MINUTES=15
# Seconds before another code can be sent
LOCAL_AUTH_CODE_RESEND_COOLDOWN=30
  1. Navigate to Users
  2. Click Add User → Add Local User
  3. Enter the email address, first and last name, job title and organization (all required), and an optional description
  4. Select Send a verification code now if the user is ready to sign in. Otherwise they can request one themselves.
  5. Create the user

Time-Limited Codes

Codes expire after LOCAL_AUTH_CODE_EXPIRES_MINUTES (default: 10 minutes)

Single-Use Codes

Each code can only be used once, preventing replay attacks

Rate Limiting

Limits login code requests to prevent abuse and enumeration attacks

Timing Attack Protection

Consistent response times prevent user enumeration via timing analysis


Integrations authenticate as API users through OAuth service clients issued by Laravel Passport.

  1. Admin creates an API user and a client

    An administrator creates an API user with its first service client, or adds a client to an existing API user

  2. Credentials delivered once

    The client ID and secret are shown only at creation; the secret is stored hashed and can’t be retrieved again

  3. Client gets an access token

    The integration exchanges the client ID and secret at POST /oauth/token for an access token that lasts one hour

  4. Token in requests

    The integration includes the access token in the Authorization: Bearer header

  5. Middleware validates

    AuthenticatePassportToken validates the token, the client’s secret expiry and IP allowlist, and authenticates the request as the client’s API user

  6. Request logged

    API request logged with metrics and analytics

See the API Documentation for complete client management details.


Every login creates a UserLoginRecord with a segment, a classification of the user at that moment. Segments feed the login analytics dashboard (charts, stats, filters) and are captured at login time so historical metrics remain accurate even if user roles change later.

Sign-in records are deleted after LOGIN_RECORD_RETENTION_DAYS (default 365). See Data Retention.

SegmentClassificationColor
Super AdminUser has the ManageAll permissionDanger (red)
External UserUser authenticates via local/passwordless authWarning (amber)
OtherEveryone else (default)Gray

All charts, filters, exports, and stats pull from the UserSegment. Adding a new case makes it appear everywhere.

  1. Add a case to the enum

    app/Domains/User/Enums/UserSegment.php
    case SuperAdmin = 'super-admin';
    case ExternalUser = 'external-user';
    case Faculty = 'faculty'; // New segment
    case Other = 'other';

    Add a color and icon for the new case in getColor() and getIcon(). The label is auto-generated from the value (e.g., 'faculty' → "Faculty").

  2. Add classification logic

    In DetermineUserSegment, add a check above the default case:

    app/Domains/User/Actions/DetermineUserSegment.php
    return match (true) {
    $this->isSuperAdmin($user) => UserSegment::SuperAdmin,
    $this->isExternalUser($user) => UserSegment::ExternalUser,
    $this->isFaculty($user) => UserSegment::Faculty,
    default => UserSegment::Other,
    };

    Cases are evaluated top-to-bottom. Order matters when a user could match multiple segments.


Authentication uses layered rate limits to prevent brute force attacks and abuse. Limits are applied per IP and per identifier (email or challenge ID) to balance security with usability. App\Domains\Auth\LoginCodes enforces the passwordless limits itself, along with the resend cooldown, because the email sign-in page’s Livewire actions don’t pass through route middleware.

Rate Limits
#RATE_LIMIT_AUTH_LOGIN_CODE_REQUEST_PER_MINUTE5

Code request attempts per minute (per IP)

#RATE_LIMIT_AUTH_LOGIN_CODE_REQUEST_PER_EMAIL_PER_MINUTE3

Code request attempts per minute (per email)

#RATE_LIMIT_AUTH_LOGIN_CODE_VERIFY_PER_MINUTE10

Code verification attempts per minute (per IP)

#RATE_LIMIT_AUTH_LOGIN_CODE_VERIFY_PER_CHALLENGE_PER_MINUTE5

Code verification attempts per minute (per challenge)

Rate Limits
#RATE_LIMIT_AUTH_IMPERSONATE_PER_MINUTE10

Impersonation actions per minute

All rate limiters are defined in config/rate-limiting.php.


Users with the ManageImpersonation permission can sign in as another user to see the application as they do. The Impersonate button on a user’s page in the administration panel posts to /impersonate/take/{id}. API users can’t be impersonated, and only a user with ManageAll can impersonate another user who has it.

While impersonating, both panels show the theme’s impersonation banner, whose leave button posts to /impersonate/leave and returns to the original account. Both routes are in routes/auth.php and share the impersonation rate limit.

Each start is recorded in the impersonation log, which is kept forever unless IMPERSONATION_LOG_RETENTION_DAYS is set. See Audit Logging.

For information on roles, permissions, and policies after a user is authenticated, see the Authorization documentation.