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/nestjsand@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/introspectwithtoken=<jwt>. It recognizes password-flow login tokens and returns{ "active": true, ... }withsub,tid,tenant_id,email, androles.Introspection is a confidential-client-only endpoint (RFC 7662 §2.1), gated by an explicit, per-client
can_introspectcapability. The caller must:- Authenticate as a registered, confidential OAuth2 client (has a secret) —
either HTTP Basic (
client_secret_basic) orclient_id+client_secretin the POST body (client_secret_post), matching that client's OWN registeredtoken_endpoint_auth_method(presenting Basic for a client registered asclient_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 aclient_id— and gets401, the same as an anonymous caller. - Have
can_introspectenabled 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/nestjsSDK'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." - Authenticate as a registered, confidential OAuth2 client (has a secret) —
either HTTP Basic (
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:
- Client-side — discard the stored access token and refresh token.
- 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
}