@aws-blocks/bb-auth-basic
Simple username/password authentication with JWT sessions, password policy, and optional code-confirmed signup and password reset.
When to use: Prototyping, internal tools, MVPs, or apps that don't need OAuth/OIDC complexity.
When NOT to use: For social sign-in (Google, GitHub), use AuthOIDC. For MFA, user groups, or custom user attributes, use AuthCognito.
Design & mock parity details: DESIGN.md
Quick Start
import { Scope, ApiNamespace } from '@aws-blocks/core';
import { AuthBasic } from '@aws-blocks/bb-auth-basic';
const scope = new Scope('my-app');
const auth = new AuthBasic(scope, 'auth', {
sessionDuration: 86400,
passwordPolicy: { minLength: 8, requireDigits: true },
// Optional: enable code-confirmed signup and password reset
codeDelivery: async (username, code) => {
await sendEmail(username, `Your verification code: ${code}`);
},
});
export const api = new ApiNamespace(scope, 'api', (context) => ({
async getProfile() {
const user = await auth.requireAuth(context);
return { username: user.username, createdAt: user.createdAt };
},
}));
// Export the state machine API for the Authenticator component
export const authApi = auth.createApi();
The client namespace is taken from the export name you choose here (so
import { authApi } from 'aws-blocks'matches); the'auth'label insidecreateApi()is internal and does not affect the wire namespace.
Frontend: render the login UI and gate the app
Exporting authApi provisions the backend, but it does not render a login screen — your SPA still shows nothing until you mount an auth component. If you stop at the backend, an authenticated app opens to a blank/landing page and every signed-in route is unreachable. Mount the shipped Authenticator UI in your SPA entrypoint and gate the app shell behind it:
// src/index.ts — the SPA entrypoint
import { api, authApi } from 'aws-blocks';
import { AccountMenuBar, AuthenticatedContent } from '@aws-blocks/blocks/ui';
// A Sign In / account control that opens the Authenticator (sign-up / sign-in /
// confirm-code / reset) when clicked. No testids to wire by hand — the shipped
// components already carry them (see auth-common/CUSTOMIZING-AUTH-UI.md).
document.getElementById('menu-bar')!.appendChild(AccountMenuBar(authApi));
// Gate the app shell: the render callback runs only when a session exists;
// the fallback shows while signed out.
const signInMessage = document.createElement('p');
signInMessage.textContent = 'Sign in to get started.';
document.getElementById('app')!.appendChild(
AuthenticatedContent(authApi, (user) => {
const el = document.createElement('div');
el.textContent = `Welcome, ${user.username}`;
// ...build the authenticated app here, calling your `api` methods...
return el;
}, signInMessage),
);
AccountMenuBar(authApi)renders the entry point that pops theAuthenticator. To embed the form inline instead, useAuthenticator(authApi)directly.AuthenticatedContent(authApi, render, fallback)subscribes to the session and swaps betweenrender(user)andfallbackon sign-in/out — this is what gates the shell.- Both come from
@aws-blocks/blocks/ui(re-exported from@aws-blocks/auth-common/ui). - The sign-up/sign-in fields and buttons ship stable testids an e2e suite can target directly —
authenticator-username,authenticator-password,authenticator-code, andauthenticator-action-signUp/authenticator-action-signIn(oneauthenticator-<field>per input, oneauthenticator-action-<action>per action). The full selector contract is inauth-common/CUSTOMIZING-AUTH-UI.md; don't fork the component to add your own.
See the scaffolded default template's src/index.ts for a complete working app built on this seam.
API
const auth = new AuthBasic(scope, id, options?)
Server-Side Methods
| Method | Returns | Description |
|---|---|---|
requireAuth(context) | Promise<AuthBasicUser> | Require auth. Throws 401 — SessionRevokedException if the session was revoked (signed out), else SessionExpiredException if absent/expired. |
checkAuth(context) | Promise<boolean> | Check if signed in. |
getCurrentUser(context) | Promise<AuthBasicUser | null> | Get current user or null. |
signUp(username, password) | Promise<void> | Register a new user. If codeDelivery is set, user is unconfirmed until code is verified. |
confirmSignUp(username, code) | Promise<void> | Confirm signup with verification code. Only when codeDelivery is set. |
signIn(username, password, context) | Promise<AuthBasicUser> | Authenticate and set session cookie. Rejects unconfirmed users. When lockout is enabled, throws AccountLockedException (429) while the account is locked after too many failed attempts. |
signOut(context) | Promise<void> | Revoke the presented session server-side (a replayed cookie is rejected) and clear the session cookie. The local cookie is always cleared; if the server-side revocation write fails it throws SignOutIncompleteException (503) so the caller can retry. |
resetPassword(username) | Promise<void> | Send a password reset code. Only when codeDelivery is set. |
confirmResetPassword(username, code, newPassword) | Promise<void> | Complete password reset with verification code. |
createApi() | ApiNamespace | Create the state machine API for the Authenticator component. |
Action input shape: createApi() returns the state-machine API. setAuthState takes a single FLAT discriminated input — { action, ...fields }, never a nested { action, data: {...} }. Examples: { action: 'signIn', username, password }, { action: 'signUp', username, password }, { action: 'confirmSignUp', username, code, password }, { action: 'resetPassword', username }, { action: 'confirmResetPassword', username, code, newPassword }. See the AuthActionInput type in @aws-blocks/auth-common.
buildApi() (deprecated)
Deprecated — use
createApi(). Retained for backward compatibility. UnlikecreateApi()(which exposes thegetAuthState/setAuthStatestate machine),buildApi()returns anApiNamespacewith a flat RPC surface. Documented here so existing callers can find the shapes. The requestcontextis captured by the namespace factory, so the exposed methods take nocontextargument:
| Method | Signature | Returns |
|---|---|---|
signUp(username, password) | (username: string, password: string) => Promise<{ username: string }> | { username }. Also sets the session cookie immediately when codeDelivery is not configured. |
signIn(username, password) | (username: string, password: string) => Promise<{ username: string }> | { username }. Sets the session cookie. |
signOut() | () => Promise<{ success: true }> | { success: true }. Revokes the presented session server-side and clears the session cookie. |
getCurrentUser() | () => Promise<AuthBasicUser | null> | The signed-in user, or null. |
Options
| Option | Type | Default | Description |
|---|---|---|---|
sessionDuration | number | 86400 (24h) | Session duration in seconds. |
passwordPolicy | PasswordPolicy | {} | Password requirements. |
codeDelivery | (username, code) => Promise<void> | — | Code delivery callback. Enables confirmed signup and password reset. |
logger | ChildLogger | — | Optional logger for internal operations. When omitted, a default Logger at error level is created. |
crossDomain | boolean | false | Set to true when the frontend and API are on different registrable domains (e.g. frontend on Vercel, API on AWS). Switches the session cookie to SameSite=None; Secure; Partitioned. |
lockout | LockoutPolicy | — (disabled) | Sign-in throttling / account lockout. Omit to disable (default). Pass {} to enable with defaults, or override maxAttempts (5), windowSeconds (900), lockoutSeconds (900). See Sign-in throttling. |
Password Policy
| Field | Type | Default | Description |
|---|---|---|---|
minLength | number | 8 | Minimum password length. |
requireUppercase | boolean | false | Require uppercase letter. |
requireLowercase | boolean | false | Require lowercase letter. |
requireDigits | boolean | false | Require digit. |
requireSpecialChars | boolean | false | Require special character. |
Code Delivery
When codeDelivery is provided:
- Signup creates the user in an unconfirmed state. The code is delivered via the callback. The user must call
confirmSignUpbefore they can sign in. - Password reset is available as a state machine action. The code is delivered via the callback. The user enters the code + new password to complete the reset.
- Verification codes are 6-digit numeric strings, valid for 10 minutes.
- Codes are HMAC-hashed before storage — never stored in plain text.
- Each code allows at most 5 failed guesses (signup confirmation and password reset alike); after the 5th wrong guess the code is invalidated and the user must request a new one. This is always on — a 6-digit code is brute-forceable within its 10-minute window without it — and independent of the opt-in sign-in lockout.
When codeDelivery is not provided:
- Signup is immediate (no confirmation step).
- Password reset is not available (the action does not appear in the state machine).
Error Handling
import { isBlocksError } from '@aws-blocks/core';
import { AuthBasicErrors } from '@aws-blocks/bb-auth-basic';
try {
await auth.signIn('alice', 'wrong', context);
} catch (e) {
if (isBlocksError(e, AuthBasicErrors.InvalidCredentials)) {
// bad username or password
}
}
| Error Constant | Name | When |
|---|---|---|
AuthBasicErrors.InvalidCredentials | InvalidCredentialsException | Wrong username or password, or unconfirmed user |
AuthBasicErrors.UserAlreadyExists | UserAlreadyExistsException | Duplicate username on sign-up |
AuthBasicErrors.InvalidPassword | InvalidPasswordException | Password doesn't meet policy |
AuthBasicErrors.SessionExpired | SessionExpiredException | requireAuth with no/expired session |
AuthBasicErrors.SessionRevoked | SessionRevokedException | requireAuth with a token that was revoked at sign-out (still unexpired, but invalidated) |
AuthBasicErrors.InvalidCode | InvalidCodeException | Wrong or expired verification code (signup or reset) |
AuthBasicErrors.SignOutIncomplete | SignOutIncompleteException | signOut cleared the local cookie but the server-side revocation write failed (e.g. a DynamoDB throttle); status 503 — retry to revoke the token |
AuthBasicErrors.AccountLocked | AccountLockedException | signIn rejected because the account is locked after too many failed attempts (only when lockout is enabled); status 429 — try again after the lockout window |
Branching on the setAuthState client path
isBlocksError works on a thrown error. The recommended client path (createApi() → setAuthState()) does not throw — it returns an AuthState whose errorName carries the same structured name. Use hasAuthError to branch on the returned state, e.g. to fall back to sign-up for a brand-new user:
import { hasAuthError } from '@aws-blocks/core';
import { AuthBasicErrors } from '@aws-blocks/bb-auth-basic';
let next = await authApi.setAuthState({ action: 'signIn', username, password });
if (hasAuthError(next, AuthBasicErrors.InvalidCredentials)) {
// unknown or wrong-credential user → offer sign-up instead
next = await authApi.setAuthState({ action: 'signUp', username, password });
}
Rule of thumb: throw path → isBlocksError; returned AuthState → hasAuthError. Never match on the human-facing error string.
UI Components
This package does not include UI components. Use the provider-agnostic Authenticator from @aws-blocks/auth-common/ui:
import { Authenticator } from '@aws-blocks/auth-common/ui';
import { authApi } from 'aws-blocks';
document.body.appendChild(Authenticator(authApi));
See the auth-common README for full UI component documentation including AuthenticatedContent, onAuthChange, and broadcastAuthChange.
AuthBasicUser
Extends the common AuthUser with:
| Field | Type | Description |
|---|---|---|
userId | string | Same as username |
username | string | The username |
createdAt | string | ISO 8601 creation timestamp |
Re-exported types
These types from @aws-blocks/auth-common are re-exported by this package, so you can import them directly from @aws-blocks/bb-auth-basic without adding a dependency on auth-common:
import type {
BlocksAuth, AuthUser, AuthState, AuthActionInput, AuthAction, AuthField,
} from '@aws-blocks/bb-auth-basic';
| Type | Kind | Shape / purpose |
|---|---|---|
BlocksAuth | interface | The common server-side auth contract AuthBasic implements: requireAuth(context): Promise<AuthUser>, checkAuth(context): Promise<boolean>, getCurrentUser(context): Promise<AuthUser | null>. |
AuthUser | interface | Base user shape: { userId: string; username: string }. AuthBasicUser extends it (adds createdAt). |
AuthState | interface | State returned by getAuthState() / setAuthState(): { state, user?, actions, error?, errorName?, retriable? }, where state is 'signedOut' | 'signedIn' | 'confirmingSignUp' | 'confirmingSignIn' | 'confirmingMfa' | 'confirmingPasswordReset'. |
AuthAction | interface | One action offered by a state: { name, label, fields: AuthField[], url?, method?: 'GET' | 'POST', capability?: 'webauthn-get' | 'webauthn-create' }. |
AuthField | interface | A form field to render: { name, label, type: 'text' | 'password' | 'email' | 'tel' | 'number' | 'hidden', required: boolean, defaultValue? }. |
AuthActionInput | type | Discriminated union (keyed on action) passed to setAuthState. The variants AuthBasic handles are signIn, signUp, confirmSignUp, signOut, resetPassword, and confirmResetPassword (see the action-input note under API). |
Cookies and sessions
The session is an HttpOnly cookie holding a signed JWT. By default the cookie is SameSite=Lax (plus Secure off localhost), which is correct for same-origin apps and the local dev proxy — the common case.
When you serve the frontend through the Hosting block, CloudFront puts the static site and the API behind one origin (the API is a path on the same distribution), so the deployed app is same-origin and the SameSite=Lax default works without any configuration — leave crossDomain at its default false.
Set crossDomain: true only when the frontend and API are served from different registrable domains in production (e.g. frontend on Vercel, API on AWS). That switches the cookie to SameSite=None; Secure; Partitioned so it survives the cross-site request:
const auth = new AuthBasic(app, 'auth', { crossDomain: true });
On plain-HTTP localhost the BB drops Secure for the Lax default and drops Partitioned for the cross-domain recipe (CHIPS requires HTTPS).
Revocation: read cost and consistency
Server-side sign-out revocation (see signOut) adds one DynamoDB GetItem per authenticated request — checkAuth, getCurrentUser, requireAuth, and the getAuthState call that runs on page load each check the token's jti against a revocation list. On an auth-gated, high-traffic app this roughly doubles read volume to the underlying table versus pure stateless JWT validation. Tokens issued before revocation shipped (no jti) skip the read and simply expire within sessionDuration.
The revocation read is an eventually-consistent GetItem (no ConsistentRead), so revocation is near-immediate, not instantaneous: for a sub-second window after signOut, a token replayed against a different Lambda instance may still authenticate before the revocation marker propagates. A signed-out token stops working everywhere within that propagation window, and always expires at its own exp regardless.
Sign-in throttling
Brute-force and credential-stuffing attacks hammer signIn with password guesses. Enable the lockout policy to lock an account after too many consecutive failures — parity with the Cognito-managed lockout you'd get from AuthCognito:
// Lock an account for 15 minutes after 5 consecutive failed sign-ins (defaults)
const auth = new AuthBasic(app, 'auth', { lockout: {} });
// Stricter: 3 attempts, 30-minute lock
const auth = new AuthBasic(app, 'auth', {
lockout: { maxAttempts: 3, lockoutSeconds: 1800 },
});
| Field | Default | Meaning |
|---|---|---|
maxAttempts | 5 | Consecutive failed sign-ins that trip the lock. |
windowSeconds | 900 (15 min) | Fixed window, measured from the first failure, over which failures are counted — once it elapses the next failure resets the running count, so slow, spaced-out guesses don't accumulate. |
lockoutSeconds | 900 (15 min) | How long the account stays locked once tripped. The lock self-expires (no manual unlock). |
While locked, signIn throws AuthBasicErrors.AccountLocked (AccountLockedException, HTTP 429) — before the password is checked, so a locked account sheds the bcrypt cost under attack and the decision carries no password-dependent timing signal. A successful sign-in clears the counter. Counting is per-username (the credential-stuffing target); it does not lock by IP, so a shared NAT isn't a false-positive risk.
Throttling is opt-in — with no lockout option, sign-in is unlimited and behaves exactly as before. Counters live in the same TTL-backed store as verification codes and revocation markers, so they self-reap and never grow unbounded.
The counter is a best-effort read-modify-write, not an atomic increment, so highly concurrent failures for the same username (across many Lambda instances) can lose an increment and make the lock trip slightly later than maxAttempts suggests. The lockout window is measured from the moment the count first reaches maxAttempts, so a lock always holds for the full lockoutSeconds once tripped.
Local development: the mock KVStore is in-memory, so lockout counters (like all AuthBasic state) reset when the dev server restarts.