Authentication¶
How players and admins get an account and prove who they are: local accounts, two-factor authentication, sessions, API tokens, and OAuth2 / OpenID Connect in both directions.
Local accounts¶
Registration¶
Registration is open by default and controlled by ctf.allow_registration in
Competition settings. Turning it off hides the form and
rejects the endpoint, so late sign-ups stop without touching anything else. It also
stops external providers from creating new
accounts; existing accounts keep signing in through them.
Whether an email address is required depends on whether email is configured:
| Email enabled | Address | Effect |
|---|---|---|
| Off | Optional | The account is usable immediately. |
| On | Required | A verification link is sent, and login is blocked until it is used. |
Registration is rate limited to 5 attempts per minute per IP address. That limit is
fixed and is not one of the Security settings. If
captcha is enabled, the registration form is challenged too. The
captcha is a self-hosted ALTCHA proof-of-work, signed with SECRET_KEY; there is no
third-party service to sign up for and nothing leaves your instance.
Turning email on mid-event
Accounts registered while email was off were created already verified, so they keep working. Only accounts created afterwards need to verify.
Email verification¶
The verification link is valid for 24 hours and works once. Players who let it lapse can request a new one from the login screen, rate limited to 3 requests per minute per IP address.
Requesting a new link does not cancel the old one
Each request mints an independent token. Every link sent in the last 24 hours stays usable until it expires or is clicked. That is harmless, since they all verify the same address, but it means resending is not a way to revoke a link that went astray.
Login is only gated on verification while email is enabled. Otherwise an unverified account could never be unlocked, so the check is skipped rather than locking people out.
Changing a user's email address unverifies it
Players cannot change their own address; an admin can, from Admin → Users → user. The new address starts unverified, and nothing is sent to it. While email is enabled, the player's next login is refused until they ask for a new link from the screen that refusal shows, and use it. There is no way for an admin to mark an address verified by hand, so tell the player before you change it. An address changed while email is off is not checked until email is turned on.
Password reset¶
Forgot password mails a single-use link valid for 1 hour. The link is consumed the moment it is opened, so a second click fails even inside the hour. Requests are rate limited to 3 per minute per IP address, and the address is matched case-insensitively.
The form always reports success, whether or not the address matches an account, so it cannot be used to test which addresses are registered. As with verification, requesting a second link leaves the first one valid until it expires, or until the password changes: a reset, by any link, retires every other link issued for the account.
Without email configured there is no self-service reset. An admin can still generate a reset link for any user from Admin → Users; the link is shown ready to copy, and can be handed over out of band. That path does not need SMTP.
Login¶
Passwords are hashed with Argon2. A login attempt against an unknown username performs the same hashing work as a real one, so response timing does not reveal which usernames exist.
Login is rate limited per IP, and unlike registration this one is configurable under
Security settings, via rate_limit.login.max_requests and
rate_limit.login.window_seconds.
Every attempt is recorded in the event log, successes as user.login and
failures as user.login_failed with the reason:
| Reason | Meaning |
|---|---|
unknown_user |
No account with that username. |
bad_password |
Wrong password. |
disabled |
Password correct, account deactivated by an admin. |
bad_totp |
Password correct, TOTP code wrong. |
email_not_verified |
Credentials correct, verification link not used yet. |
A deactivated account is only told so once the password is right; until then it gets
the same answer as any wrong password. Re-authentication failures
are recorded the same way, with the reason prefixed reauth_.
A run of unknown_user failures from one address is the signature of username
enumeration; a run of bad_password against one account is a brute-force attempt.
Admin → Security → Failed logins does that grouping for you: failed attempts per address over the last 24 hours or the whole event, with the usernames each address tried, most usernames first. An address that tried several usernames is what password spraying and credential stuffing look like. The Usernames column filters on Several or One, Known accounts on whether a tried username Matches an account, and the search box finds any address or username.
Two-factor authentication¶
Any user can enrol a TOTP authenticator from their own profile settings. It is per-user and opt-in. There is no instance-wide setting that forces it on, so ask your admins to enable it rather than assuming.
Enrolment is the usual three steps: the platform shows a secret and QR code, the user scans it, and the setup is only committed once they enter a valid code. The pending secret is held for 10 minutes; an enrolment that is not confirmed in that window expires and leaves the account unchanged.
Once enabled, login asks for the 6-digit code after the password. Codes from the immediately preceding and following time step are accepted, so a device whose clock drifts by up to 30 seconds still works. Each code is accepted once: replaying one that was just used is refused, even inside its time step.
Disabling TOTP requires a current code, so someone who walks up to an unlocked browser cannot quietly remove it.
There are no recovery codes
A user who loses their authenticator cannot recover the account themselves. An admin has to remove it with Reset TOTP under Admin → Users → user, after which the user signs in with the password alone and can enrol again. Make sure at least one admin account is reachable by someone other than the person who owns it.
Sessions¶
Signing in sets a signed NexCTF cookie, valid for 24 hours and marked Secure
outside development. Session records live in PostgreSQL, not in the cache, so restarting
the cache container does not sign anyone out. Expired sessions are purged every night.
Idle session timeout (minutes), under Security settings
(auth.session_idle_minutes), also signs a browser session out after that long without
a request, on top of the 24 hour limit. It defaults to 0, which disables it, and goes
up to 1440. It does not apply to API tokens.
From their profile settings a user can see every active session, with its browser user agent, originating and most recent IP address, and last activity. Any session can be revoked individually, or all of them at once. Signing out on every device also revokes every API token the user holds.
Admins see the same list for any account under Admin → Users → user, and can sign
one device out from there without touching the password. The session an admin is
currently using is marked as such, so they do not revoke their own by accident. Each
revocation is recorded in the event log as admin.user_session_revoked,
with the target account in the metadata.
Admin → Security → Sessions turns the same records around, listing addresses rather than accounts: who is signed in right now, or who signed in over the last 24 hours or the whole event, grouped by the address they came from, the addresses with the most accounts first. A shared address is marked Same team or Different teams; the second is the quick way to spot one player running accounts on two teams. The Accounts column filters on Shared or Single account, Teams on Different teams or Same team, and the search box finds any address, username or team.
The Clients tab lists the user-agents each account has been seen with over the same windows, and whether it came through a Browser session or an API token, naming the token. Each user-agent is sorted into Browser, AI or Automation by matching it against two lists of patterns under the Beta settings, AI user-agent patterns and Automation user-agent patterns, one pattern per line, matched case-insensitively anywhere in the string. A client sending no user-agent at all counts as automation. The same list for one account is under Admin → Users → user.
A user-agent is a hint, not proof
The client sends its own user-agent, and anything can send any string. A match is worth a look; the absence of one proves nothing.
Revoking a session is not the same as locking an account out
A revoked session only ends that one device. Someone who still knows the password signs straight back in. To actually stop an account, deactivate it: every session and API token it holds stops working at once.
These end every session of an account at once:
- changing or resetting the account password, which also revokes every API token
- signing out on every device, which also revokes every API token
- an admin deactivating the account, which also stops its API tokens
- an admin granting the account the admin role, so that it signs in again before using it
Rotating SECRET_KEY invalidates every session on the instance, since the cookie
signature no longer verifies.
Re-authentication¶
Some actions need a recent sign-in, not just a valid session: the browser session must
have proved who it is within the last 10 minutes. Past that, the request is refused
with 401 AUTH-REAUTH-REQUIRED, and the interface asks Confirm it's you before
retrying it. The user enters their password, plus a TOTP code if 2FA is on; an account
without a password confirms with its TOTP code, or by signing in again through its
provider. Confirming renews the same session in place, so it stays on the session list
as one device.
Attempts are limited by the same rate limit as the login form, and recorded in the
event log, as user.reauth on success and as user.login_failed with a
reauth_ reason on failure.
| Needs a recent sign-in | API tokens |
|---|---|
| Creating an API token, changing the password, setting up, enabling or disabling TOTP, unlinking a provider | Refused, see below |
| An admin creating an admin account, granting the admin role, changing a user's email address, resetting a user's TOTP, issuing a password-reset link | Refused |
| Downloading, restoring or deleting a database backup | Accepted, no re-authentication |
| Creating, editing or deleting an OAuth provider or OAuth client | Accepted |
| Deleting a user, demoting or deactivating an admin | Accepted |
| Saving the registration switch, the login rate limit, the idle timeout, the captcha switch, any email setting, or custom CSS | Accepted |
A token is its own proof, so where tokens are accepted at all, they skip the check.
API tokens¶
Tokens are the non-interactive way in, for scripts and integrations. A user creates them from their profile settings; each has a name and an optional expiry, and a user can hold several.
Send one as a bearer token:
| Property | Behaviour |
|---|---|
| Prefix | Every token starts with nexctf_, which makes them easy to catch in secret scanners. |
| Storage | Only a SHA-256 hash is stored. The value is shown once, at creation. |
| Expiry | Optional. Without one the token is valid until revoked. An expired token is deleted a week after it expires. |
| Revocation | Immediate, from the same screen. |
| Last used | Shown next to each token, or Never used. Updated at most once every five minutes, so a token in constant use can read up to five minutes behind. |
| Permissions | Chosen per token, from the scopes below. A token never carries more than it was granted. |
Token scopes¶
A token is granted a set of verb:group scopes at creation, and every request it makes
is checked against them. The verb comes from the HTTP method: GET, HEAD and
OPTIONS need read, and POST, PUT, PATCH and DELETE need write. The group
comes from the endpoint.
| Group | Covers |
|---|---|
profile |
The owner's own profile and team membership. |
token |
Listing and revoking API tokens. |
challenge |
Challenges and their attachments, including answer submission. |
scoreboard |
The scoreboard. |
team |
Teams. |
notification |
Notifications and the event stream. |
content |
Pages and site info. |
plugin.<key> |
Endpoints the plugin <key> mounts on the player side. One group per installed plugin. |
Admins can additionally grant the administrative groups:
| Group | Covers |
|---|---|
admin.challenge |
Challenges, questions, solutions, hints, files, submissions, score adjustments. |
admin.user |
Users, custom fields, and the session, failed-login and client views under Admin → Security. |
admin.team |
Teams. |
admin.scoreboard |
Scoreboard and statistics. |
admin.notification |
Notifications and email. |
admin.content |
Pages and links. |
admin.config |
Settings, the event log, the scheduler, feedback, OAuth providers and clients, plugins. |
admin.backup |
Creating, downloading, deleting and restoring database backups. |
admin.bundle |
Exporting the event's content as a bundle, and importing one, under Admin → Sync. |
admin.plugin.<key> |
Endpoints the plugin <key> mounts on the admin side. One group per installed plugin. |
A plugin group only exists while its plugin is loaded, so a token can only be granted one for a plugin that is installed and not disabled.
Three rules are worth knowing before you build the list:
- Granting a write grants the matching read. Asking for
write:admin.challengestoresread:admin.challengealongside it, so you never have to tick both. - A player cannot grant an admin group. The scopes on offer are narrowed to the
owner's own role, and a request for one outside it is refused with
SCOPE-422. - A token needs at least one scope. Creating one with an empty list is refused.
Scopes are fixed for the life of the token. To widen or narrow one, revoke it and issue a replacement.
Every endpoint publishes the scope it needs
The OpenAPI schema carries an x-token-scope field on each operation, and the
rendered API docs repeat it in the description, so you can read off exactly what a
script needs rather than granting broadly and hoping. Public endpoints, which need
no authentication at all, carry no scope and say so instead.
A request that reaches an endpoint outside its scopes is refused with 403
SCOPE-403, naming the scope it was missing.
Endpoints a token cannot reach¶
Some endpoints refuse bearer authentication outright and answer 403
SCOPE-403-SESSION, whatever scopes the token carries:
- creating an API token
- revoking one of your own sessions, or all of them
- unlinking an OAuth provider
- changing the password
- enrolling in or disabling TOTP
- as an admin: creating an admin account, granting the admin role, changing a user's email address, resetting a user's TOTP, or issuing a password-reset link
These are the operations that change how an account is secured, and requiring a browser session keeps a leaked token from being used to entrench itself.
The admin audit channel of the live event stream follows the same logic in a softer
form: an admin's token only receives it when it holds read:admin.config.
An admin token with admin scopes is an admin credential
Scopes narrow what a token can reach, not who it acts as. A token granted
write:admin.user can create administrators. Grant the narrowest set that does the
job, and treat a broadly scoped token like the password.
DEFAULT_ADMIN_TOKEN provisions a token at first start for automation, and it is
granted every scope available to an admin; see
Deployment. It is a bootstrap credential, not
one to keep in use: issue a narrowly scoped token for each job and revoke the default.
Tokens bypass 2FA by design
A token authenticates on its own, and TOTP is a login-flow check rather than a per-request one. That is what makes unattended scripts possible, and why an admin token deserves the same handling as a password.
Logging in with an external provider¶
NexCTF can delegate login to any OpenID Connect provider: Keycloak, Authentik, Auth0, GitLab, Google, and others. Providers are configured at Admin → OAuth providers, and each one you activate adds a button to the login page.
The provider has to be a full OpenID Connect one. Its discovery document must advertise
an issuer, a JWKS URL and a userinfo endpoint, and its token response must carry an ID
token. NexCTF checks that ID token's signature, issuer, audience and nonce, and that
the userinfo sub matches it, and it sends a PKCE (S256) challenge with every sign-in.
A plain OAuth2 provider with no ID token, such as a GitHub OAuth app, is refused.
| Field | Notes |
|---|---|
| Slug | URL-safe identifier. It appears in the callback URL, so pick it before registering the client and do not change it afterwards. |
| Name | Label shown on the login button. |
| Client ID / Client secret | Issued by the provider. |
| Discovery URL | The provider's .well-known document. Endpoints are read from it, so you never enter them individually. |
| Scopes | Space-separated. Defaults to openid email profile. Keep openid, or the provider returns no ID token. |
| Icon URL | Optional image for the login button. |
| Active | Inactive providers are hidden from the login page and their endpoints return 404. |
Register this redirect URI with the provider, substituting your BACKEND_HOST and the
slug you chose:
The redirect URI depends on DOMAIN
It is built from BACKEND_HOST, which is derived from DOMAIN unless you set it
explicitly. Leave both unset and the platform sends the provider a callback address
on http://localhost:8000, and the flow fails. If your public URL is not
https://<DOMAIN>, set BACKEND_HOST yourself. See
Deployment.
How accounts are matched¶
On a successful callback the provider's subject identifier is looked up:
- Known subject → sign in to the account it is linked to.
- Unknown subject, user already signed in → link the provider to the current account.
- Unknown subject, nobody signed in → create a new account, if registration is open. Otherwise the sign-in is refused.
New accounts take their username from the provider (preferred_username, then login,
then the subject id) and get a short random suffix if that name is already taken. The
provider's email is copied over and marked verified, but only when the provider marks it
email_verified; otherwise the account is created without one.
Accounts are never matched by email address
A matching email does not attach a provider identity to an existing local account. If it did, a provider that hands out unverified email addresses could be used to take over any account by claiming its address. Users who want both must sign in locally first, then link the provider from their profile settings.
The other side of that guarantee: if the provider's email is already claimed by another local account, the new account is created without an email address rather than colliding.
Users can see their linked providers, and unlink them, from their profile settings.
Unlinking can lock an account out
An account created through a provider has no password. Unlinking its only provider leaves no way in. Set a password first.
Using NexCTF as a provider¶
The other direction: NexCTF is itself an OAuth2 authorization server, so other applications can offer Sign in with NexCTF. Useful for a writeup site, a Discord bot, or anything else you run alongside the event that should share the same accounts.
Register applications at Admin → OAuth clients.
| Field | Notes |
|---|---|
| Name / Description | Shown to the user on the consent screen. |
| Redirect URIs | One per line. A callback that is not listed exactly is refused. |
| Allowed scopes | Space-separated subset of the scopes below. |
| Allowed roles | Space-separated, from admin and user. Leave empty to allow everyone. |
| Active | Inactive clients are refused. |
The client ID is generated for you; the client secret is shown once at creation and stored only as a hash. Losing it means rotating it.
What the server supports¶
| Discovery | /api/v1/oauth2/.well-known/oauth-authorization-server |
| Grant type | authorization_code |
| Response type | code |
| PKCE | Supported, S256 only |
| Client authentication | client_secret_post |
| Authorization code lifetime | 10 minutes, single use |
| Access token lifetime | 1 hour |
There are no refresh tokens: the client repeats the authorization flow when the access token expires.
Scopes¶
| Scope | Grants |
|---|---|
openid |
A stable user identifier. |
profile |
The username. |
email |
The email address. |
roles |
The platform role: admin or user. |
Request roles when the other application needs to mirror your admin list; without it a
client cannot tell an admin from a player.
Scopes are checked again when the code is exchanged: the token only carries the ones the client is still allowed, so narrowing a client's Allowed scopes also applies to codes already issued. A client that sent a PKCE challenge must send its verifier, and one that did not must not send one.
Role restrictions are enforced on every authorization
Allowed roles is checked when the user reaches the consent screen, not only at registration. Demoting a user from admin to player immediately stops them authorizing an admin-only client, though an access token issued beforehand remains valid until it expires.
The issuer and endpoint URLs in the discovery document come from BACKEND_HOST. As with
external providers, an unset BACKEND_HOST advertises localhost to your clients.