How to Use the crypto/elliptic Package in Go

Use crypto/elliptic to select standard curves like P256 and perform point operations for cryptographic math.

When the standard library isn't enough

You are building a custom cryptographic protocol for a research project. Or you are debugging a TLS handshake and need to verify that a server's public key actually sits on the expected curve. Or you are implementing a zero-knowledge proof from scratch and need raw point arithmetic. The high-level packages in Go's standard library handle keys, signatures, and exchanges automatically. They hide the math. When you need to touch the coordinates directly, you reach for crypto/elliptic.

This package does not generate keys. It does not sign messages. It gives you the raw geometric engine. You get curve definitions, point addition, and scalar multiplication. You get the building blocks that every modern cryptographic system rests on.

The math behind the package

An elliptic curve is a set of points that satisfy a specific equation, usually written as y² = x³ + ax + b. The magic happens when you define how to add two points together. The addition rule draws a line through two points, finds where it intersects the curve again, and reflects that intersection across the x-axis. The result is a third point on the same curve.

Scalar multiplication takes this further. Instead of adding two points, you add a point to itself many times. If P is a point and k is a large integer, k * P means P + P + P ... repeated k times. The operation is easy to compute in one direction. Reversing it is computationally infeasible. That one-way property is the foundation of elliptic curve cryptography.

Go's crypto/elliptic package exposes this as a simple interface. You pick a curve. You get a Curve interface that handles the arithmetic. The package ships with standard curves like P256, P384, and P521. These are NIST curves, widely audited, and mandated by most security standards. The package also supports Curve25519 and Curve448 through separate subpackages, but crypto/elliptic focuses on the Weierstrass-form curves.

Elliptic curves are not magic. They are carefully chosen mathematical structures that make one direction fast and the other direction impossible.

A minimal point operation

Here is the simplest way to interact with the package. You create a curve instance, feed it a scalar, and get back the coordinates of the resulting point.

package main

import (
	"crypto/elliptic"
	"fmt"
	"math/big"
)

func main() {
	// P256 is the most common curve for TLS and SSH.
	curve := elliptic.P256()

	// ScalarBaseMult computes k * G, where G is the curve's base point.
	// The scalar must be a byte slice matching the curve's byte size.
	scalar := make([]byte, curve.Params().N.BitLen()/8+1)
	scalar[0] = 0x01 // Use a small scalar for demonstration.

	x, y := curve.ScalarBaseMult(scalar)

	// The package returns big.Int values, not raw bytes.
	// This preserves precision across all supported curves.
	fmt.Printf("X: %s\nY: %s\n", x.Text(16), y.Text(16))
}

The code creates a P256 curve, prepares a scalar, and multiplies it against the curve's generator point. The output prints the hexadecimal coordinates. Notice the big.Int return type. Go's standard library refuses to truncate cryptographic values. Every coordinate is a full-precision arbitrary-precision integer.

What happens under the hood

When you call elliptic.P256(), the function returns a struct that implements the elliptic.Curve interface. The struct holds the curve parameters: the prime field modulus P, the coefficients A and B, the base point G, and the order N. These values are baked into the standard library. You do not compute them at runtime.

ScalarBaseMult takes your byte slice, converts it to a *big.Int, and performs modular scalar multiplication. The implementation uses a double-and-add algorithm optimized for constant-time execution. Constant time matters because timing side channels can leak private keys. The standard library avoids early returns and conditional branches that depend on secret data.

The function returns two *big.Int pointers. Go conventions dictate that functions returning multiple values should document them clearly. The elliptic package follows the rule: the first return is the x-coordinate, the second is the y-coordinate. Both are pointers because big.Int is a reference type under the hood, and copying it would defeat the purpose of arbitrary precision.

If you pass a scalar that is too large, the function reduces it modulo the curve order N. If you pass a malformed byte slice, the package handles it gracefully rather than panicking. The design prioritizes safety over speed.

Cryptographic primitives in Go favor explicit types and safe defaults. Trust the big.Int pointers.

A realistic verification workflow

In practice, you rarely just multiply points. You usually receive coordinates from a network connection and need to verify they are valid. A malicious peer might send a point that looks correct but actually lives on a different curve, or a point with a small order that breaks your protocol.

Here is how you validate incoming coordinates and perform a custom operation.

package main

import (
	"crypto/elliptic"
	"fmt"
	"math/big"
)

// VerifyPoint checks if the provided coordinates form a valid point on the curve.
// It returns true if the point satisfies the curve equation and order constraints.
func VerifyPoint(curve elliptic.Curve, x, y *big.Int) bool {
	// IsOnCurve handles the heavy lifting: field validation, equation check, and order verification.
	// It protects against invalid curve attacks and small-subgroup attacks.
	if !curve.IsOnCurve(x, y) {
		return false
	}

	// Points at infinity are represented as nil coordinates in some protocols.
	// The standard library treats nil as the identity element.
	if x == nil || y == nil {
		return true
	}

	return true
}

func main() {
	curve := elliptic.P256()

	// Simulate receiving coordinates from a remote peer.
	// We generate a valid point first to demonstrate the check.
	scalar := make([]byte, curve.Params().N.BitLen()/8+1)
	scalar[len(scalar)-1] = 0x02
	validX, validY := curve.ScalarBaseMult(scalar)

	// Verify the point before using it in any protocol logic.
	if VerifyPoint(curve, validX, validY) {
		fmt.Println("Point is valid on P256")
	}

	// Demonstrate point addition using the internal Add method.
	// Add takes two points and returns their sum on the curve.
	sumX, sumY := curve.Add(validX, validY, validX, validY)
	fmt.Printf("Doubled point X: %s\n", sumX.Text(16))
}

The VerifyPoint function wraps curve.IsOnCurve. This single method checks three things. It confirms the coordinates are within the field range. It verifies they satisfy y² = x³ + ax + b modulo p. It ensures the point has the correct order. Skipping this check is a common vulnerability in custom implementations.

The Add method shows how to combine two arbitrary points. You pass the x and y of the first point, then the x and y of the second point. The method returns the resulting x and y. The signature looks verbose, but it avoids allocating temporary structs. Go's elliptic package prefers explicit parameters over opaque point objects.

Always validate external coordinates before arithmetic. An invalid point can derail your entire protocol.

Pitfalls and compiler behavior

The package is straightforward, but it trips up developers who expect convenience methods. You will not find a Marshal or Unmarshal function in crypto/elliptic. Those live in crypto/ecdsa and crypto/ecdh. If you try to call curve.Marshal(x, y), the compiler rejects the program with curve.Marshal undefined (type elliptic.Curve has no field or method Marshal). The interface is intentionally narrow.

Another common mistake involves byte length. Curves expect scalars and coordinates in a specific byte format. P256 uses 32 bytes. P384 uses 48 bytes. If you pass a 16-byte slice to a 256-bit curve, the package pads it with zeros. If you pass a slice that is too long, it truncates from the left. The behavior is documented, but it catches people off guard. The compiler will not save you here. It only checks types. A []byte is a []byte.

Runtime panics happen when you ignore the big.Int nature of the returns. If you try to convert a *big.Int to an int without checking bounds, you get a panic. The error message reads runtime error: bigint overflow. Always use Int64() or Uint64() with explicit range checks, or stick to byte conversion with Bytes().

Memory allocation is another quiet trap. big.Int operations allocate new pointers. If you run scalar multiplication in a tight loop, you generate garbage. The standard library does not reuse buffers for you. You must manage your own *big.Int pools if performance matters. This is where sync.Pool becomes relevant for high-throughput services.

The compiler enforces types. The runtime enforces bounds. You enforce allocation strategy.

Convention and community patterns

Go developers follow a few unwritten rules when working with low-level crypto. The receiver name for methods on curve types is usually one or two letters matching the type. You will see (c *p256Curve) Add(...), not (this *p256Curve). The community prefers short, predictable names over verbose ones.

Error handling follows the standard pattern. If a function returns an error, you check it immediately. if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. You will not see deferred error handling or panic recovery in production crypto code.

Formatting is handled by gofmt. Do not argue about indentation or brace placement. Let the tool decide. Most editors run it on save. The crypto/elliptic package itself is formatted this way, and your code should match.

Public names start with a capital letter. Private start lowercase. There are no keywords like public or private. If you export a helper function, capitalize it. If it stays internal, keep it lowercase. The language uses visibility rules, not access modifiers.

Convention is not bureaucracy. It is the friction that keeps large codebases readable.

When to reach for this package

Go's standard library separates concerns deliberately. You should pick the right tool based on your actual requirement.

Use crypto/elliptic when you need raw point arithmetic for a custom protocol, academic research, or cryptographic auditing. Use crypto/ecdh when you need to perform a secure key exchange like X25519 or P256 without writing the math yourself. Use crypto/ecdsa when you need to sign or verify digital signatures with standard curves. Use crypto/x509 when you need to parse certificates, extract public keys, or handle TLS handshakes. Use golang.org/x/crypto/curve25519 when you need optimized, constant-time implementations of the Curve25519 family outside the standard library. Use plain sequential code when you don't need cryptography: the simplest thing that works is usually the right thing.

Where to go next