Authentication Endpoints

Endpoints for user registration, login, passwordless sign-in, password management, email verification, and social OAuth.

Most authentication endpoints require the X-Bulwark-Tenant header. You may also include the optional X-Bulwark-App-Id header to associate the authentication event with a specific application. The one bootstrap exception is Identify App, which resolves the tenant from a publishable key.

Passwordless sign-in (magic link, email OTP) and the second-factor / passkey endpoints live on the MFA & Passkeys page; the passwordless senders are also summarized below.


Identify App

POST /api/v1/auth/identify

Resolves a publishable key to its tenant + enabled auth methods. Called by the SDKs on init to discover what login options to render. Public — no tenant header required.

Body

{
  "publishable_key": "pk_live_..."
}

Response 200

{
  "tenant_id": "ten_01j...",
  "tenant_slug": "acme-prod",
  "display_name": "Acme",
  "environment": "production",
  "branding": {},
  "auth_methods": {
    "password": true,
    "passkeys": true,
    "google": true,
    "github": false,
    "mfa_policy": "optional"
  }
}

Returns 401 if the publishable key is invalid.


Register

POST /api/v1/auth/register

Body

{
  "email": "[email protected]",
  "password": "supersecret",
  "display_name": "Jane Doe"
}

Response 201

{
  "user_id": "usr_01j...",
  "access_token": "eyJ...",
  "refresh_token": "rt_...",
  "expires_in": 900
}

register adds a user to an existing tenant (resolved from the publishable key or X-Bulwark-Tenant). To create a brand-new workspace, use Sign Up.

Existing-email behavior (anti-enumeration)

To prevent account enumeration, register never returns 409. For an already-registered email it still returns 201, but with a generic body and no tokens:

{ "status": "check your email to complete registration", "user_id": "" }

A brand-new registration returns user_id and access_token. So a public client distinguishes the two cases by the presence of access_token — not by status code. Note one nuance: access_token is also withheld (with verification_required: true) when the tenant requires email verification, so "no token" alone doesn't imply "already existed."

If you need a clean 409 on duplicates, use the authenticated admin endpoint POST /api/v1/admin/users — an admin context is not an enumeration vector, so it returns 409 Conflict for an existing email.


Sign Up

POST /api/v1/auth/signup

Self-service onboarding: creates a new organization + tenant (workspace) and an admin user who owns it. Public. (Contrast with Register, which adds a user to an existing tenant.)

Body

{
  "email": "[email protected]",
  "password": "supersecret",
  "display_name": "Ada Lovelace",
  "company_name": "Acme"
}

company_name is required; display_name is optional.

Response 201

{
  "user_id": "usr_01j...",
  "tenant_id": "ten_01j...",
  "access_token": "eyJ...",
  "refresh_token": "rt_...",
  "expires_in": 900,
  "email_verified": false
}

If the email already exists, the response is still 201 with a generic "check your email" message (to prevent account enumeration). Can be disabled per deployment.


Login

POST /api/v1/auth/login

Body

{
  "email": "[email protected]",
  "password": "supersecret"
}

Response 200

{
  "user_id": "usr_01j...",
  "access_token": "eyJ...",
  "refresh_token": "rt_...",
  "expires_in": 900,
  "email_verified": false,
  "mfa_required": false
}

Access token claims & verification

The access_token is a signed EdDSA (Ed25519) JWT. Its claims include:

| Claim | Meaning | | --- | --- | | sub | User ID | | tid | Home tenant (workspace) ID — canonical | | tenant_id | Duplicate of tid for SDK convenience | | org_id | Active organization (when applicable) | | email | User email — always present on login-flow tokens | | roles | Role slugs | | sid | Login session ID | | exp, iat, iss, aud | Standard registered claims (iss: "bulwark") |

To validate a token server-side, either:

  • Verify the signature locally against the JWKS at /.well-known/jwks.json (alg: EdDSA, kty: OKP). This is the recommended path for a resource server — no network round-trip per request. Do not trust an unverified local decode. @bulwarkauth/nestjs and @bulwarkauth/nextjs (>=0.5.0) both do this for you by default — see their SDK pages instead of implementing it yourself.

  • Call introspection: POST /oauth2/introspect with token=<jwt>. It recognizes password-flow login tokens and returns { "active": true, ... } with sub, tid, tenant_id, email, and roles.

    Introspection is a confidential-client-only endpoint (RFC 7662 §2.1), gated by an explicit, per-client can_introspect capability. The caller must:

    1. Authenticate as a registered, confidential OAuth2 client (has a secret) — either HTTP Basic (client_secret_basic) or client_id + client_secret in the POST body (client_secret_post), matching that client's OWN registered token_endpoint_auth_method (presenting Basic for a client registered as client_secret_post, or vice versa, is rejected). A public client (no secret, token_endpoint_auth_method: none) cannot use this endpoint at all — with or without a client_id — and gets 401, the same as an anonymous caller.
    2. Have can_introspect enabled on that client. This is settable only through Bulwark's authenticated admin API — never through Dynamic Client Registration (RFC 7591) or RFC 7592 self-service client configuration — specifically so a self-registering caller in a DCR-enabled tenant can never grant itself the ability to use introspection as a token-liveness oracle. Create a confidential client via the admin API and enable introspection on it (the @bulwarkauth/nestjs SDK's README has a worked example).

    Both checks must pass; a wrong secret and a correct secret with introspection disabled both return an identical 401, so the response alone cannot be used to distinguish "bad credentials" from "not enabled for this client."

GET /oauth2/userinfo is part of the OAuth2/OIDC flow and only accepts tokens issued through the OAuth2 authorization-code path — not password-login tokens. For login-flow tokens, read email directly from the JWT claim above.

Bulwark's own APIs vs. OAuth2-issued access tokens

Bulwark's own first-party APIs (everything under /api/v1/... and any other JWT-gated route, via RequireJWT/RequireJWTWithDenylist) only ever accept a first-party login JWT (iss: "bulwark", minted directly by /api/v1/auth/login and the other first-party auth endpoints — password, magic link, email OTP, passkey, social, SSO), an API key, or a Biscuit capability. An OAuth2-issued access token (minted by /oauth2/token for a third-party client via authorization_code, refresh_token, or client_credentials — iss is this server's own OAuth2 issuer URL, not "bulwark") is deliberately rejected on every first-party route, even though it is a validly signed Bulwark-issued JWT. This is a policy choice, not a gap: OAuth2 access tokens exist so a relying party can call its own resource server (validating the token via /oauth2/introspect, above, or locally against the JWKS), not so it can re-enter Bulwark's own management APIs. Agents that need to call Bulwark's own APIs do so via a Biscuit capability, not an OAuth2 access token.


Refresh Token

POST /api/v1/auth/refresh

Body

{
  "refresh_token": "rt_..."
}

Response 200

{
  "user_id": "usr_01j...",
  "access_token": "eyJ...",
  "refresh_token": "rt_new_...",
  "expires_in": 900,
  "email_verified": true
}

Logout

There is no dedicated /auth/logout endpoint. Access tokens are short-lived JWTs that are not server-side revocable on their own, so logout is performed in two parts:

  1. Client-side — discard the stored access token and refresh token.
  2. Server-side (optional but recommended) — revoke the underlying login session so the refresh token can no longer mint new access tokens.

Revoke the current device's session with the sessions API:

DELETE /api/v1/me/sessions/{id}

To revoke every session for the current user (sign out everywhere):

DELETE /api/v1/me/sessions

Forgot Password

POST /api/v1/auth/forgot-password

Body

{
  "email": "[email protected]"
}

Response 200

{
  "message": "if that email exists, a reset link has been sent"
}

Reset Password

POST /api/v1/auth/reset-password

Body

{
  "token": "reset_...",
  "new_password": "newpassword"
}

Response 200

{
  "message": "password reset successfully"
}

Passwordless — Magic Link

POST /api/v1/auth/magic-link

Emails the user a one-time login link. The token is delivered by email only; verify it with the endpoint below.

Body

{
  "email": "[email protected]",
  "redirect_url": "https://app.example.com/magic-link"
}

redirect_url (optional) is the page in your app that receives the link. Bulwark appends ?token=… (keeping any query you pass) and your page calls the verify endpoint with it. Its origin must be one of your application's allowed origins — the app identified by X-Bulwark-Publishable-Key, or the tenant's when you send only X-Bulwark-Tenant.

If you omit redirect_url, Bulwark falls back to your application's configured default — auth_methods.magic_link_url in Settings — checked-in first for the app identified by X-Bulwark-Publishable-Key, then for the tenant. That default is still validated against the same allowed-origins list as an explicit redirect_url (defense in depth). If neither the app nor the tenant has a default configured, the request fails with 400 rather than falling back to any Bulwark-owned page — there is no dashboard page that consumes a bare magic-link token, so a silent fallback there would just produce a dead link.

Response 200

{
  "status": "sent",
  "message": "Magic link sent to the provided email address"
}

The response is identical whether or not the email belongs to a user, so it cannot be used to discover accounts.

Errors

| Status | Meaning | | --- | --- | | 400 | Missing email; redirect_url (or the resolved default) is not an allowed origin for this application; or redirect_url was omitted and no default magic_link_url is configured for this application ("redirect_url is required: no default magic-link URL is configured for this application") | | 500 | Email delivery is not configured for this deployment |

Email branding

For apps that share a workspace's user pool (see Applications), the magic-link email uses the calling app's own branding — its branding.app_name, logo, and colors (set via the applications branding endpoint) — rather than the workspace's or the Bulwark default, provided the request carries X-Bulwark-Publishable-Key. Requests that only send X-Bulwark-Tenant get the tenant's branding, as before. The same app-first rule applies to email OTP, signup verification, verification resend, and password reset emails.

Verify

POST /api/v1/auth/magic-link/verify
{
  "token": "<token-from-the-emailed-link>"
}

Returns an access token on success. Note: passwordless verification returns an access_token only — no refresh token / session (unlike password and passkey login).

{
  "user_id": "usr_01j...",
  "access_token": "eyJ...",
  "expires_in": 900
}

Errors: 401 invalid link, 410 expired.


Passwordless — Email OTP

POST /api/v1/auth/otp/send

Emails the user a one-time verification code.

Body

{
  "email": "[email protected]"
}

Response 200

{
  "challenge_id": "chl_01j...",
  "status": "sent",
  "message": "Verification code sent to the provided email address"
}

Verify

POST /api/v1/auth/otp/verify
{
  "code": "123456"
}
{
  "user_id": "usr_01j...",
  "access_token": "eyJ...",
  "expires_in": 900
}

Errors: 401 invalid code, 410 expired, 429 too many attempts. Access token only — no refresh token.


Verify Email

POST /api/v1/auth/verify-email

Confirms a user's email address using the token from the verification email.

Body

{
  "token": "verify_..."
}

Response 200

{
  "status": "email verified"
}

A GET variant accepts the token as a query parameter, so a plain link in an email works without a form:

GET /api/v1/auth/verify-email?token=verify_...

Both return 422 if the token is invalid, expired, or already used.


Resend Verification

POST /api/v1/auth/resend-verification

Sends a fresh verification email to the logged-in user. No-op if already verified.

Headers

  • Authorization: Bearer <accessToken>

Response 200

{
  "status": "verification email sent"
}

Current User

GET /api/v1/auth/me

Returns the authenticated user plus their tenant and the sibling applications in the workspace. (For just the user's editable profile, use GET /api/v1/user/profile.)

Headers

  • Authorization: Bearer <accessToken>

Response 200

{
  "user": {
    "id": "usr_01j...",
    "email": "[email protected]",
    "display_name": "Jane Doe",
    "roles": ["user"]
  },
  "tenant": {
    "id": "ten_01j...",
    "name": "Acme",
    "slug": "acme-prod",
    "display_name": "Acme",
    "environment": "production",
    "settings": {}
  },
  "workspace": {
    "id": "wsp_01j...",
    "applications": [
      { "id": "ten_01j...", "name": "Acme", "environment": "production" }
    ]
  }
}

Social OAuth — Redirect

GET /api/v1/auth/social/{provider}/authorize

Supported providers: google, github, microsoft

Redirects the user to the provider's OAuth consent page.

Query Parameters

  • tenant_id — Required. The tenant to authenticate against.
  • redirect_uri — Optional. URI to redirect the user to after a successful login (token appended as #token=). Must be in the tenant's allowed redirect URI list.

Social OAuth — Callback

GET /api/v1/auth/social/{provider}/callback

Called by the provider after consent. Returns tokens.

Response 200

{
  "user_id": "usr_01j...",
  "access_token": "eyJ...",
  "refresh_token": "rt_...",
  "expires_in": 900,
  "is_new_user": false
}