Authentication systems are complicated (honestly, too much so for a single question). Most web app frameworks include at least part of one in-box, and if you want to write your own, you're at significant risk of missing something important or introducing a vulnerability you didn't anticipate. That's not to say you never should, but it's risky. You need to not only get the actual authentication right (which ideally means not only username+password, but also other credentials like passkeys and multiple factors), you also need to handle account registration and removal, forgotten/lost credentials, and session management.
A note on terminology: "token" and "cookie" are not the same class of thing. A token is a credential, generally ephemeral and machine-generated, presented to authenticate requests. JWTs are usually (not always, despite the name) used as tokens, but random strings are common too and a few others exist. Cookies are a place to store data on the client, and are notable for automatically submitting that data to the server with requests. Most cookies do not hold tokens, but it is common to place tokens in cookies. However, there are other places to store data including tokens or other credentials, and other places to submit it (the most common ones being local/session storage and the Authorization header with the Bearer prefix, respectively).
If you own your entire app/service - that is, you control both the login process and all the data that the app uses - then there is no need or use for OAuth in your system. OAuth is for granting one app access to another service's data, or (in combination with OIDC) for single sign-on (SSO); neither is relevant here. Directly submitting credentials against your authentication endpoint works fine, and is much less error-prone.
You should consider the types of credentials you accept, and whether you want to allow (or require) multi-factor authentication.
- Passwords are pretty bad security, honestly; we use them widely because they're simple to understand and easy to implement poorly (which means they got implemented a lot in early authentication systems and are consequently well-entrenched), but implementing them well is hard. You need:
- Strong slow password hashing (but not too slow lest you put yourself at risk of DoS). Don't use fast hashing algorithms like the SHA family. Be prepared to increase the cost (or "work factor") of the password hashing algorithm over time. Bcrypt is better than nothing but its memory requirement is low enough that it can still be parallelized somewhat effectively on high-end hardware; there are newer options with tunable memory cost.
- Restrictions on allowed passwords (a.k.a. password policies). These should look much more like "forbid every password ever seen in a credential leak" than "require both upper and lower case characters plus numbers"; the latter is mostly useless and arguably harmful. At an absolutely minimum, though, set a decent minimum length (at least 8 characters, but sometimes you want longer minimums) and ban the most common passwords (there's lists online you can use).
- Brute-forcing protection. Because passwords are relatively weak, attackers will try to force their way into other peoples' accounts by brute-force guessing likely passwords. Ideally all such likely passwords will be blocked already, but some amount of brute-force prevention is still a good idea. Just don't make it such that it blocks out legitimate users if somebody on the same external IP gets a password wrong a few times, though...
- A system for handling the inevitable case where people forget their passwords, generally some form of password reset using an alternative credential. Note that if you offer MFA, password reset should NOT let somebody sign in with only one factor (such as email account access) alone.
- If your system is at all sensitive, you should at least offer multi-factor authentication, especially if passwords are one required factor.
- Email is the standard alternate credential (or rather, vehicle for delivering an alternate credential), usually used as a fallback option for password reset, but sometimes used regularly as a primary or secondary factor via "magic links" or other one-time credentials. It can be inconvenient on some devices but is generally available, and has the advantage that an account almost always has an associated email address already; no need for additional enrollment.
- SMS is the only common form of online user authentication that is perhaps worse than passwords; SIM-jacking attacks are the most common way to compromise them, though direct interception and device or account theft can do it too, and there's no good equivalent of using a password manager with long random passwords to harden SMS. It's also unreliable when the user is traveling, doesn't have signal, or doesn't have their phone handy. Certainly don't use SMS for single-factor authentication to anything at all sensitive.
- TOTP (Time-based One Time Password) such as Google Authenticator has the device requirement of SMS but is otherwise much better. There's no way to avoid storing the keys server-side, but at least they're unique per user and per site.
- Webauthn (Yubikeys and similar, platform auth like Face/TouchID and Windows Hello) is extremely strong, and in particular the only option with good phishing protection. "Passkeys" are built on webauthn. The main downside, aside from being relatively novel and not fully supported on all client platforms, is that it can be difficult to move between client devices (Apple is attempting to solve this for passkeys in particular, but it only works on Apple devices that are signed into your iCloud account).
After authentication, consider the type of token(s) you want to issue, the token lifetime(s), and token storage and transmission vectors.
- JWT (JSON Web Token): Popular and lots of libraries exist, many of which don't even have severe security flaws (some do, like accepting
alg: none). Doesn't require server-side state, which is good (but not essential) for scalability. Can't be revoked without introducing server-side state, so they're valid until they expire, which usually means they need to be extremely short-lived. Critical single point of failure if the signing key is ever compromised. For a system like yours, no reason to use asymmetric signatures instead of HMACs.
- Secure random strings. One of the classic token types. Require storage on the server, which can introduce latency and/or cause caching issues across a cluster. Less prone to catastrophic security failure than JWTs, but still has some potential flaws (like SQL injection, if the tokens aren't hashed before storage on the server). Can easily be updated or revoked at any time, meaning they can have arbitrary lifetimes. Generally opaque; a user looking at one can't tell who it's for, whether it's valid, or what access it grants.
- Access or session token: presented on every authenticated request, so the server knows who you are. Can be any format.
- Refresh token: presented to the server when (or just before) a JWT or similar signed token expires, to obtain a new one. Requires a server-stored token format, can't itself be a JWT or similar. In practice this means JWTs need to be paired with random tokens anyhow, though the random token is much less frequently needed.
- Identity token: not relevant here and included only for completeness, a non-opaque token format (usually a JWT) that directly conveys information (generally user identity info) rather than merely granting access to a server where information is available. Sometimes also used to grant access.
Token storage and transmission:
- Cookies: enduringly popular option, despite cookies' relatively poor default security model. Can be made invisible to scripts (slightly mitigates XSS, though XSS is still very bad indeed) via
httponly flag. Must be made to only be available in secure contexts (HTTPS requests) via
secure flag. Can work across subdomains but this has its own risks. Can self-expire, but you should never trust the client to take a security action like that. Opens you to CSRF (Cross-Site Request Forgery) attacks, though various mitigations exist (the
samesite flag is one, but has serious weaknesses when dealing with subdomains). Cause a lot of confusion. However, they are convenient - hence the widespread usage - because they are automatically submitted to the server with every request.
- Bearer token: a custom value for the HTTP
Authorization request header, the token value is prefixed with the string "Bearer " to indicate the purpose of the header. Can't be set by cross-origin requests without CORS configuration, and cant be used with forged requests because the attacker doesn't know the value (even if CORS is so badly misconfigured that sending the request via a browser on the attacker's site is possible). The token must be manually set for each authenticated request. Transmission, not storage, option.
- Session or local storage: a key-value data store found on modern browsers, useful for storing data that must be accessible to scripts but needs to persist across page reloads or navigation (as script variables do not). Commonly used to store bearer tokens, among other data. Not self-expiring (except session storage wiping when the browser is fully exited, though again don't count in this), and inherently visible to scripts including injected malicious ones (XSS).
Hope this helps. It's a huge topic; I didn't even really get to session management or authorization.